1use serde_json::Value;
54use std::future::Future;
55use std::pin::Pin;
56use std::sync::OnceLock;
57
58use crate::prune::{prune, PruneConfig};
59
60use super::trim::{estimate_tokens, estimate_value_tokens, repair_orphaned_tool_calls};
61use crate::tokens::TokenEstimation;
62
63pub type SummarizeFuture = Pin<Box<dyn Future<Output = anyhow::Result<String>> + Send>>;
65
66pub type SummarizeFn = dyn Fn(String) -> SummarizeFuture + Send + Sync;
71
72pub type Summarizer = Box<SummarizeFn>;
74
75pub const SUMMARY_PREFIX: &str = "[CONTEXT COMPACTION — REFERENCE ONLY]";
78
79pub const SUMMARY_END_MARKER: &str = "--- END OF CONTEXT SUMMARY ---";
81
82pub(crate) fn is_compaction_message(m: &Value) -> bool {
91 m["content"]
92 .as_str()
93 .is_some_and(|c| c.starts_with(SUMMARY_PREFIX))
94}
95
96pub(crate) fn is_compaction_text(content: &str) -> bool {
100 content.starts_with(SUMMARY_PREFIX)
101}
102
103const TAIL_MIN_MESSAGES: usize = 3;
106
107const SUMMARY_INPUT_MSG_CAP: usize = 2_000;
109
110const THRASH_MIN_SAVINGS: f32 = 0.10;
113const GAP_MIN_PROGRESS: f32 = 0.25;
117const ABS_MIN_RECLAIM_TOKENS: usize = 200;
119
120#[derive(Debug)]
129pub struct CompressState {
130 last_savings: [f32; 2],
132 last_effective: [bool; 2],
136 attempts: usize,
137 disabled: bool,
138 notified: bool,
139 failopen_notified: bool,
143}
144
145impl Default for CompressState {
146 fn default() -> Self {
147 Self::new()
148 }
149}
150
151impl CompressState {
152 pub fn new() -> Self {
153 Self {
154 last_savings: [1.0, 1.0],
155 last_effective: [true, true],
156 attempts: 0,
157 disabled: false,
158 notified: false,
159 failopen_notified: false,
160 }
161 }
162
163 fn record(&mut self, tokens_before: usize, tokens_after: usize, budget: usize) {
174 let relative = if tokens_before > 0 {
175 1.0 - (tokens_after as f32 / tokens_before as f32)
176 } else {
177 0.0
178 };
179 let gap_before = tokens_before.saturating_sub(budget);
180 let gap_after = tokens_after.saturating_sub(budget);
181 let effective = tokens_after <= budget
182 || (gap_before > 0
183 && (gap_after as f32) <= (gap_before as f32) * (1.0 - GAP_MIN_PROGRESS))
184 || tokens_before.saturating_sub(tokens_after) >= ABS_MIN_RECLAIM_TOKENS
185 || relative >= THRASH_MIN_SAVINGS;
186 self.last_savings = [self.last_savings[1], relative];
187 self.last_effective = [self.last_effective[1], effective];
188 self.attempts += 1;
189 if self.attempts >= 2 && !self.last_effective[0] && !self.last_effective[1] {
190 self.disabled = true;
191 }
192 }
193
194 pub fn is_disabled(&self) -> bool {
198 self.disabled
199 }
200
201 pub fn reset(&mut self) {
205 *self = Self::new();
206 }
207
208 #[doc(hidden)]
211 pub fn latch_disabled_for_tests(&mut self) {
212 self.disabled = true;
213 self.notified = true;
214 }
215
216 pub fn counters(&self) -> CompressCounters {
220 let strikes = if self.attempts == 0 || self.last_effective[1] {
225 0
226 } else if self.attempts >= 2 && !self.last_effective[0] {
227 2
228 } else {
229 1
230 };
231 CompressCounters {
232 compressions: self.attempts,
233 strikes,
234 disabled: self.disabled,
235 last_reclaim: (self.attempts > 0).then_some(self.last_savings[1]),
236 }
237 }
238
239 fn take_notice(&mut self) -> Option<String> {
242 if self.disabled && !self.notified {
243 self.notified = true;
244 Some(
245 "context compression was ineffective twice in a row — auto-compression \
246 is disabled for this session; start a new conversation to reset"
247 .to_string(),
248 )
249 } else {
250 None
251 }
252 }
253
254 fn take_failopen_notice(&mut self) -> Option<String> {
261 if !self.failopen_notified {
262 self.failopen_notified = true;
263 Some(
264 "context exceeds the proven-good budget, but no authoritative window \
265 limit is known for this model — dispatching over budget and letting \
266 the backend decide; an accepted size raises the learned budget"
267 .to_string(),
268 )
269 } else {
270 None
271 }
272 }
273}
274
275#[derive(Debug, Clone, Copy, PartialEq)]
279pub struct CompressCounters {
280 pub compressions: usize,
283 pub strikes: usize,
286 pub disabled: bool,
289 pub last_reclaim: Option<f32>,
292}
293
294#[derive(Debug, Clone, Copy, PartialEq, Eq)]
300pub(crate) struct CompressTrigger {
301 pub budget: usize,
303 pub max_messages: Option<usize>,
306 pub hard_budget: bool,
310}
311
312pub(crate) fn compression_trigger(
327 len: usize,
328 current_tokens: usize,
329 message_tokens: usize,
330 count_threshold: usize,
331 token_threshold: Option<usize>,
332 send_budget: Option<usize>,
333 tool_tokens: usize,
334) -> Option<CompressTrigger> {
335 let token_threshold = token_threshold.filter(|&b| b > 0);
339 let send_budget = send_budget.filter(|&b| b > 0);
340
341 let count_fired = len > count_threshold;
342 let token_fired = token_threshold.is_some_and(|b| current_tokens > b);
343 let guard_fired = send_budget.is_some_and(|b| current_tokens > b);
344 if !(count_fired || token_fired || guard_fired) {
345 return None;
346 }
347 let mut budget = usize::MAX;
348 if token_fired {
349 budget = budget.min(token_threshold.unwrap_or(usize::MAX));
350 }
351 if guard_fired {
352 budget = budget.min(
353 send_budget
354 .unwrap_or(usize::MAX)
355 .saturating_sub(tool_tokens),
356 );
357 }
358 let hard_budget = budget != usize::MAX;
359 if !hard_budget {
360 budget = message_tokens / 2;
366 }
367 Some(CompressTrigger {
368 budget,
369 max_messages: count_fired.then_some(count_threshold / 2),
370 hard_budget,
371 })
372}
373
374pub(crate) struct CompressRequest<'a> {
380 pub messages: &'a [Value],
381 pub budget: usize,
384 pub max_messages: Option<usize>,
387 pub task: &'a str,
389 pub authoritative: bool,
397 pub hard_budget: bool,
404 pub focus: Option<&'a str>,
410 pub est: crate::tokens::TokenEstimation,
413 pub summary_input_cap_floor_chars: usize,
417 pub compaction_store: Option<&'a dyn crate::agentic::spill::SpillStore>,
422}
423
424impl<'a> CompressRequest<'a> {
425 pub(crate) fn user_initiated(
435 messages: &'a [Value],
436 task: &'a str,
437 focus: Option<&'a str>,
438 est: TokenEstimation,
439 summary_input_cap_floor_chars: usize,
440 ) -> Self {
441 Self {
442 messages,
443 budget: estimate_tokens(messages, est) / 2,
444 max_messages: None,
445 task,
446 hard_budget: false,
447 authoritative: true,
450 focus,
451 est,
452 summary_input_cap_floor_chars,
453 compaction_store: None,
456 }
457 }
458}
459
460#[derive(Debug, Clone, Copy, PartialEq, Eq)]
462pub(crate) enum CompressAction {
463 Fit,
465 Pruned,
467 Summarized,
469 StaticFallback,
472 Refused,
476 DispatchedOverBudget,
483}
484
485impl CompressAction {
486 pub(crate) fn describe(self) -> &'static str {
488 match self {
489 Self::Fit => "no change",
490 Self::Pruned => "structural prune",
491 Self::Summarized => "prune + summary",
492 Self::StaticFallback => "prune + static marker",
493 Self::Refused => "refused",
494 Self::DispatchedOverBudget => "over budget — dispatched",
495 }
496 }
497}
498
499pub(crate) struct CompressOutcome {
501 pub messages: Vec<Value>,
502 pub action: CompressAction,
503 pub fired: bool,
505 pub tokens_before: usize,
506 pub tokens_after: usize,
507 pub notice: Option<String>,
509}
510
511pub(crate) async fn compress(
516 req: CompressRequest<'_>,
517 summarizer: Option<&SummarizeFn>,
518 state: &mut CompressState,
519) -> CompressOutcome {
520 let tokens_before = estimate_tokens(req.messages, req.est);
521 let tokens_over_entry = req.hard_budget && tokens_before > req.budget;
526 let over = |tokens: usize, len: usize| {
527 tokens > req.budget || req.max_messages.is_some_and(|m| len > m)
528 };
529
530 if !over(tokens_before, req.messages.len()) {
531 return CompressOutcome {
532 messages: req.messages.to_vec(),
533 action: CompressAction::Fit,
534 fired: false,
535 tokens_before,
536 tokens_after: tokens_before,
537 notice: None,
538 };
539 }
540 let mut force_marker = false;
544 if state.disabled && req.hard_budget {
545 if tokens_over_entry {
546 if req.authoritative {
547 force_marker = true;
554 } else {
555 return CompressOutcome {
562 messages: req.messages.to_vec(),
563 action: CompressAction::DispatchedOverBudget,
564 fired: false,
565 tokens_before,
566 tokens_after: tokens_before,
567 notice: state.take_failopen_notice(),
568 };
569 }
570 } else {
571 return CompressOutcome {
575 messages: req.messages.to_vec(),
576 action: CompressAction::Fit,
577 fired: false,
578 tokens_before,
579 tokens_after: tokens_before,
580 notice: state.take_notice(),
581 };
582 }
583 }
584
585 let pruned = prune(req.messages, &PruneConfig::default());
587 let prune_changed = pruned.chars_reclaimed > 0;
588 let pruned = pruned.messages;
589 let after_prune = estimate_tokens(&pruned, req.est);
590 if !over(after_prune, pruned.len()) {
591 if tokens_over_entry {
592 state.record(tokens_before, after_prune, req.budget);
593 }
594 return CompressOutcome {
595 messages: pruned,
596 action: CompressAction::Pruned,
597 fired: prune_changed,
598 tokens_before,
599 tokens_after: after_prune,
600 notice: state.take_notice(),
601 };
602 }
603
604 let boundary = compute_boundary(&pruned, req.budget, req.max_messages, req.est);
607 let middle = &pruned[boundary.head..boundary.tail_start];
608
609 let (mut assembled, mut action) = if middle.is_empty() {
610 (pruned.clone(), CompressAction::Pruned)
612 } else {
613 let body = if force_marker {
617 None
618 } else {
619 match summarizer {
620 Some(f) => {
621 let middle_cap = req
632 .est
633 .chars_for_tokens(req.budget)
634 .max(req.summary_input_cap_floor_chars);
635 summarize_middle(f, req.task, middle, middle_cap, req.focus).await
636 }
637 None => None,
638 }
639 };
640 let action = if body.is_some() {
641 CompressAction::Summarized
642 } else {
643 CompressAction::StaticFallback
644 };
645 let mut body = body.unwrap_or_else(|| static_fallback_text(middle.len()));
646 if let Some(crumb) = reread_breadcrumb(middle) {
652 body.push_str("\n\n");
653 body.push_str(&crumb);
654 }
655 if let Some(store) = req.compaction_store {
663 let verbatim: String = middle
664 .iter()
665 .map(render_message)
666 .collect::<Vec<_>>()
667 .join("\n");
668 let id = store.store(redact_secrets(&verbatim));
669 body.push_str(&format!(
670 "\n\n[the full verbatim text of this compacted span is retrievable with \
671 memory_fetch(\"compaction:{id}\") — use it to recover an exact detail \
672 this summary dropped, instead of guessing]"
673 ));
674 }
675 let mut out = Vec::with_capacity(boundary.head + 1 + (pruned.len() - boundary.tail_start));
677 out.extend_from_slice(&pruned[..boundary.head]);
678 out.push(summary_message(&body));
679 out.extend_from_slice(&pruned[boundary.tail_start..]);
680 (out, action)
681 };
682
683 repair_orphaned_tool_calls(&mut assembled);
685
686 if estimate_tokens(&assembled, req.est) > req.budget {
700 let aggressive = prune(
701 &assembled,
702 &PruneConfig {
703 keep_last: trailing_tool_group_len(&assembled).max(2),
704 ..PruneConfig::default()
705 },
706 );
707 if aggressive.chars_reclaimed > 0 {
708 assembled = aggressive.messages;
709 if action == CompressAction::Fit {
710 action = CompressAction::Pruned;
711 }
712 }
713 if req.hard_budget
723 && estimate_tokens(&assembled, req.est) > req.budget
724 && reclaim_within_trailing_group(&mut assembled, req.budget, req.est)
725 && action == CompressAction::Fit
726 {
727 action = CompressAction::Pruned;
728 }
729 }
730
731 if force_marker && estimate_tokens(&assembled, req.est) > req.budget {
736 return CompressOutcome {
737 messages: req.messages.to_vec(),
738 action: CompressAction::Refused,
739 fired: false,
740 tokens_before,
741 tokens_after: tokens_before,
742 notice: state.take_notice(),
743 };
744 }
745
746 let tokens_after = estimate_tokens(&assembled, req.est);
747 let fired =
748 prune_changed || assembled.len() != req.messages.len() || tokens_after != tokens_before;
749 if tokens_over_entry && !force_marker {
752 state.record(tokens_before, tokens_after, req.budget);
753 }
754 CompressOutcome {
755 messages: assembled,
756 action,
757 fired,
758 tokens_before,
759 tokens_after,
760 notice: state.take_notice(),
761 }
762}
763
764#[derive(Debug, Clone)]
772pub struct ManualCompressOutcome {
773 pub messages: Vec<Value>,
775 pub fired: bool,
778 pub messages_before: usize,
779 pub messages_after: usize,
780 pub tokens_before: usize,
782 pub tokens_after: usize,
783 pub how: &'static str,
788 pub notice: Option<String>,
790}
791
792pub async fn compress_user_initiated(
810 messages: &[Value],
811 focus: Option<&str>,
812 summarizer: Option<&SummarizeFn>,
813 state: &mut CompressState,
814 est: crate::tokens::TokenEstimation,
815 summary_input_cap_floor_chars: usize,
816) -> ManualCompressOutcome {
817 let task = messages
818 .iter()
819 .find(|m| m["role"].as_str() == Some("user") && !is_compaction_message(m))
820 .and_then(|m| m["content"].as_str())
821 .unwrap_or_default()
822 .to_string();
823 let outcome = compress(
824 CompressRequest::user_initiated(messages, &task, focus, est, summary_input_cap_floor_chars),
825 summarizer,
826 state,
827 )
828 .await;
829 if outcome.fired {
830 state.record(
832 outcome.tokens_before,
833 outcome.tokens_after,
834 outcome.tokens_before / 2,
835 );
836 }
837 let notice = outcome.notice.or_else(|| state.take_notice());
838 ManualCompressOutcome {
839 messages_before: messages.len(),
840 messages_after: outcome.messages.len(),
841 fired: outcome.fired,
842 tokens_before: outcome.tokens_before,
843 tokens_after: outcome.tokens_after,
844 how: outcome.action.describe(),
845 notice,
846 messages: outcome.messages,
847 }
848}
849
850struct Boundary {
855 head: usize,
858 tail_start: usize,
861}
862
863fn compute_boundary(
869 messages: &[Value],
870 budget: usize,
871 max_messages: Option<usize>,
872 est: TokenEstimation,
873) -> Boundary {
874 let head = head_len(messages);
875 let max_tail = max_messages.map(|m| m.saturating_sub(head + 1).max(1));
876
877 let tail_budget = (budget / 4).max(1);
880 let mut tail_start = messages.len();
881 let mut acc = 0usize;
882 let mut kept = 0usize;
883 while tail_start > head {
884 if max_tail.is_some_and(|m| kept >= m) {
885 break;
886 }
887 let t = estimate_value_tokens(&messages[tail_start - 1], est);
888 if kept >= TAIL_MIN_MESSAGES && acc + t > tail_budget {
889 break;
890 }
891 acc += t;
892 kept += 1;
893 tail_start -= 1;
894 }
895
896 if let Some(last_user) = messages
902 .iter()
903 .rposition(|m| m["role"].as_str() == Some("user") && !is_compaction_message(m))
904 {
905 if last_user >= head {
906 tail_start = tail_start.min(last_user);
907 }
908 }
909
910 while tail_start > head && messages[tail_start]["role"].as_str() == Some("tool") {
914 tail_start -= 1;
915 }
916
917 if let Some(max_tail) = max_tail {
927 let cap_start = messages.len().saturating_sub(max_tail);
928 if tail_start < cap_start {
929 tail_start = cap_start;
930 while tail_start > head && messages[tail_start]["role"].as_str() == Some("tool") {
931 tail_start -= 1;
932 }
933 }
934 }
935
936 Boundary { head, tail_start }
937}
938
939fn head_len(messages: &[Value]) -> usize {
944 let mut head = 0;
945 while head < messages.len() && messages[head]["role"].as_str() == Some("system") {
946 head += 1;
947 }
948 if head < messages.len()
949 && messages[head]["role"].as_str() == Some("user")
950 && !is_compaction_message(&messages[head])
951 {
952 head += 1;
953 }
954 head
955}
956
957fn trailing_tool_group_len(messages: &[Value]) -> usize {
977 messages
978 .iter()
979 .rposition(|m| m["tool_calls"].as_array().is_some_and(|t| !t.is_empty()))
980 .map_or(0, |i| messages.len() - i)
981}
982
983fn reclaim_within_trailing_group(
998 assembled: &mut Vec<Value>,
999 budget: usize,
1000 est: TokenEstimation,
1001) -> bool {
1002 let group_len = trailing_tool_group_len(assembled);
1003 if group_len == 0 {
1004 return false;
1005 }
1006 let group_start = assembled.len() - group_len;
1007 let outside = estimate_tokens(&assembled[..group_start], est);
1008 let group_tokens = estimate_tokens(&assembled[group_start..], est);
1009 if group_tokens <= budget.saturating_sub(outside) {
1010 return false;
1013 }
1014 let result_idxs: Vec<usize> = (group_start..assembled.len())
1016 .filter(|&i| assembled[i]["role"].as_str() == Some("tool"))
1017 .collect();
1018 let mut changed = false;
1019 for &i in result_idxs.iter().take(result_idxs.len().saturating_sub(1)) {
1020 let pass = prune(
1024 assembled,
1025 &PruneConfig {
1026 keep_last: assembled.len() - i - 1,
1027 ..PruneConfig::default()
1028 },
1029 );
1030 if pass.chars_reclaimed > 0 {
1031 *assembled = pass.messages;
1032 changed = true;
1033 }
1034 if estimate_tokens(assembled, est) <= budget {
1035 break;
1036 }
1037 }
1038 changed
1039}
1040
1041fn static_fallback_text(removed: usize) -> String {
1048 format!("Summary generation was unavailable. {removed} message(s) were removed.")
1049}
1050
1051#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1056enum ConvShape {
1057 Coding,
1059 General,
1061}
1062
1063fn middle_shape(middle: &[Value]) -> ConvShape {
1070 let has_tools = middle
1071 .iter()
1072 .any(|m| m["tool_calls"].as_array().is_some_and(|t| !t.is_empty()));
1073 if has_tools {
1074 ConvShape::Coding
1075 } else {
1076 ConvShape::General
1077 }
1078}
1079
1080fn reread_breadcrumb(middle: &[Value]) -> Option<String> {
1088 let mut paths: Vec<String> = Vec::new();
1089 for m in middle {
1090 if m["role"].as_str() != Some("assistant") {
1091 continue;
1092 }
1093 let Some(calls) = m["tool_calls"].as_array() else {
1094 continue;
1095 };
1096 for call in calls {
1097 let func = &call["function"];
1098 if !matches!(
1100 func["name"].as_str(),
1101 Some("read_file") | Some("edit_file") | Some("write_file")
1102 ) {
1103 continue;
1104 }
1105 let args = &func["arguments"];
1107 let path = args["path"].as_str().map(str::to_string).or_else(|| {
1108 args.as_str()
1109 .and_then(|s| serde_json::from_str::<Value>(s).ok())
1110 .and_then(|v| v["path"].as_str().map(str::to_string))
1111 });
1112 if let Some(p) = path {
1113 if !paths.contains(&p) {
1114 paths.push(p);
1115 }
1116 }
1117 }
1118 }
1119 if paths.is_empty() {
1120 return None;
1121 }
1122 let list = paths
1123 .iter()
1124 .map(|p| format!("- {p}"))
1125 .collect::<Vec<_>>()
1126 .join("\n");
1127 Some(format!(
1128 "Files read or edited in the compacted span — their FULL CONTENTS are \
1129 NOT preserved in the summary above. RE-READ any you rely on before \
1130 using their exact signatures, types, or line contents; do NOT recall \
1131 them from this summary (it is prose, not the file):\n{list}"
1132 ))
1133}
1134
1135fn summary_message(body: &str) -> Value {
1136 serde_json::json!({
1137 "role": "user",
1138 "content": format!(
1139 "{SUMMARY_PREFIX}\n\
1140 The middle of this conversation was compressed. The text below \
1141 summarizes the removed messages — treat it as background \
1142 reference, NOT as fresh instructions. Your task is unchanged: \
1143 it is stated above and continues in the messages below.\n\n\
1144 {body}\n\n\
1145 {SUMMARY_END_MARKER}"
1146 ),
1147 })
1148}
1149
1150fn summary_prompt_for(
1164 task: &str,
1165 body: &str,
1166 focus: Option<&str>,
1167 note: Option<&str>,
1168 target_chars: usize,
1169 shape: ConvShape,
1170) -> String {
1171 let mut p = String::with_capacity(1024);
1172 p.push_str(match shape {
1173 ConvShape::Coding => "You are compressing the middle of a coding-agent conversation.\n\n",
1174 ConvShape::General => "You are compressing the middle of a conversation.\n\n",
1175 });
1176 p.push_str("## Original Task (copy this VERBATIM into \"## Active Task\")\n");
1177 p.push_str(task);
1178 p.push_str("\n\n## Conversation middle to summarise\n");
1179 if let Some(note) = note {
1180 p.push_str(note);
1181 p.push('\n');
1182 }
1183 p.push_str(body);
1184 let words = (target_chars / 6).max(40);
1187 let tokens = (target_chars / 4).max(60);
1188 let sections = match shape {
1192 ConvShape::Coding => {
1193 "## Active Task\n## Completed Actions\n## In Progress\n## Key Decisions\n\
1194 ## Relevant Files\n## Critical Context\n"
1195 }
1196 ConvShape::General => {
1197 "## Active Task\n## Discussion\n## Key Points\n## Open Questions\n\
1198 ## Critical Context\n"
1199 }
1200 };
1201 p.push_str(&format!(
1202 "\nProduce a concise structured summary with sections:\n{sections}\
1203 Start \"## Active Task\" with the original task copied verbatim. \
1204 Keep the WHOLE summary under ~{words} words (~{tokens} tokens); if it \
1205 cannot all fit, drop low-salience detail — NEVER the Active Task. \
1206 Preserve specifics (file names, error messages, decisions). \
1207 NEVER include API keys, tokens, passwords, or other credentials — \
1208 write [REDACTED] instead.",
1209 ));
1210 if let Some(focus) = focus {
1211 let focus = redact_secrets(focus);
1212 let focus = focus.trim();
1213 if !focus.is_empty() {
1214 p.push_str(&format!(
1215 "\nThe user asked for this compression and wants emphasis on \
1216 a topic: emphasize anything about {focus} — give it the bulk \
1217 of the summary's detail while keeping every section above."
1218 ));
1219 }
1220 }
1221 p
1222}
1223
1224fn summary_request(
1225 task: &str,
1226 middle: &[Value],
1227 middle_cap_chars: usize,
1228 focus: Option<&str>,
1229 shape: ConvShape,
1230) -> String {
1231 let rendered: Vec<String> = middle.iter().map(render_message).collect();
1235 let mut start = rendered.len();
1236 let mut total = 0usize;
1237 while start > 0 {
1238 let len = rendered[start - 1].chars().count();
1239 if start < rendered.len() && total + len > middle_cap_chars {
1240 break;
1241 }
1242 total += len;
1243 start -= 1;
1244 }
1245 let note = (start > 0).then(|| {
1246 format!(
1247 "[{start} older message(s) omitted from this summary input to fit \
1248 the summarizer's window]"
1249 )
1250 });
1251 let body: String = rendered[start..].concat();
1252 summary_prompt_for(
1253 task,
1254 &body,
1255 focus,
1256 note.as_deref(),
1257 middle_cap_chars / 3,
1258 shape,
1259 )
1260}
1261
1262async fn summarize_middle(
1272 summarizer: &SummarizeFn,
1273 task: &str,
1274 middle: &[Value],
1275 cap_chars: usize,
1276 focus: Option<&str>,
1277) -> Option<String> {
1278 let shape = middle_shape(middle);
1280 let rendered: Vec<String> = middle.iter().map(render_message).collect();
1281 let total: usize = rendered.iter().map(|r| r.chars().count()).sum();
1282 if total <= cap_chars {
1283 let req = redact_secrets(&summary_request(task, middle, cap_chars, focus, shape));
1286 return run_summary(summarizer, req).await;
1287 }
1288 let chunks = chunk_strings(&rendered, cap_chars);
1289 let n = chunks.len();
1290 let mut partials = Vec::with_capacity(n);
1291 for (i, chunk) in chunks.iter().enumerate() {
1292 let note = format!("[part {}/{} of the conversation middle]", i + 1, n);
1293 let req = redact_secrets(&summary_prompt_for(
1294 task,
1295 chunk,
1296 focus,
1297 Some(¬e),
1298 cap_chars / 3,
1299 shape,
1300 ));
1301 if let Some(s) = run_summary(summarizer, req).await {
1302 partials.push(s);
1303 }
1304 }
1305 reduce_partials(summarizer, task, partials, cap_chars, focus, shape).await
1306}
1307
1308fn chunk_strings(parts: &[String], cap: usize) -> Vec<String> {
1312 let mut chunks = Vec::new();
1313 let mut cur = String::new();
1314 let mut cur_len = 0usize;
1315 for p in parts {
1316 let len = p.chars().count();
1317 if cur_len > 0 && cur_len + len > cap {
1318 chunks.push(std::mem::take(&mut cur));
1319 cur_len = 0;
1320 }
1321 cur.push_str(p);
1322 cur_len += len;
1323 }
1324 if !cur.is_empty() {
1325 chunks.push(cur);
1326 }
1327 chunks
1328}
1329
1330fn reduce_partials<'a>(
1334 summarizer: &'a SummarizeFn,
1335 task: &'a str,
1336 partials: Vec<String>,
1337 cap_chars: usize,
1338 focus: Option<&'a str>,
1339 shape: ConvShape,
1340) -> std::pin::Pin<Box<dyn std::future::Future<Output = Option<String>> + Send + 'a>> {
1341 Box::pin(async move {
1342 match partials.len() {
1343 0 => None,
1344 1 => partials.into_iter().next(),
1345 _ => {
1346 let joined_len: usize = partials.iter().map(|p| p.chars().count() + 2).sum();
1347 if joined_len <= cap_chars {
1348 let body = partials.join("\n\n");
1349 let note = format!(
1350 "[{} partial summaries of ONE conversation — consolidate into one]",
1351 partials.len()
1352 );
1353 let req = redact_secrets(&summary_prompt_for(
1354 task,
1355 &body,
1356 focus,
1357 Some(¬e),
1358 cap_chars / 3,
1359 shape,
1360 ));
1361 return run_summary(summarizer, req).await;
1362 }
1363 let groups = chunk_strings(&partials, cap_chars);
1364 if groups.len() >= partials.len() {
1365 return Some(partials.join("\n\n"));
1367 }
1368 let mut next = Vec::with_capacity(groups.len());
1369 for g in &groups {
1370 let req = redact_secrets(&summary_prompt_for(
1371 task,
1372 g,
1373 focus,
1374 Some("[partial summaries — consolidate]"),
1375 cap_chars / 3,
1376 shape,
1377 ));
1378 if let Some(s) = run_summary(summarizer, req).await {
1379 next.push(s);
1380 }
1381 }
1382 reduce_partials(summarizer, task, next, cap_chars, focus, shape).await
1383 }
1384 }
1385 })
1386}
1387
1388async fn run_summary(summarizer: &SummarizeFn, req: String) -> Option<String> {
1391 match summarizer(req).await {
1392 Ok(s) if !s.trim().is_empty() => Some(s),
1393 Ok(_) => None,
1394 Err(e) => {
1395 tracing::warn!(error = %e, "compression summarizer failed — static marker fallback");
1396 None
1397 }
1398 }
1399}
1400
1401fn render_message(m: &Value) -> String {
1408 let role = m["role"].as_str().unwrap_or("unknown");
1409 let mut line = format!("[{role}]");
1410 if let Some(tcs) = m["tool_calls"].as_array() {
1411 for tc in tcs {
1412 let name = tc["function"]["name"].as_str().unwrap_or("tool");
1413 let args = tc["function"]["arguments"].to_string();
1414 line.push_str(" called ");
1415 line.push_str(name);
1416 line.push('(');
1417 line.push_str(&excerpt(&redact_secrets(&args), 200));
1418 line.push(')');
1419 }
1420 }
1421 if let Some(content) = m["content"].as_str() {
1422 if !content.is_empty() {
1423 line.push(' ');
1424 line.push_str(&excerpt(&redact_secrets(content), SUMMARY_INPUT_MSG_CAP));
1425 }
1426 }
1427 line.push('\n');
1428 line
1429}
1430
1431fn excerpt(s: &str, max_chars: usize) -> String {
1433 if s.chars().count() <= max_chars {
1434 s.to_string()
1435 } else {
1436 let head: String = s.chars().take(max_chars).collect();
1437 format!("{head}…")
1438 }
1439}
1440
1441const REDACTION_TABLE: &[(&str, &str)] = &[
1449 (
1451 r"(?s)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?(?:-----END [A-Z ]*PRIVATE KEY-----|\z)",
1452 "[REDACTED]",
1453 ),
1454 (r"\bsk-[A-Za-z0-9_-]{20,}", "[REDACTED]"),
1456 (r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}", "[REDACTED]"),
1458 (r"\bgithub_pat_[A-Za-z0-9_]{20,}", "[REDACTED]"),
1459 (r"\bAKIA[0-9A-Z]{16}\b", "[REDACTED]"),
1461 (
1463 r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}",
1464 "[REDACTED]",
1465 ),
1466 (
1469 r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{20,}",
1470 "Bearer [REDACTED]",
1471 ),
1472 (
1478 r#"(?i)\b(api[_-]?key|secret[_-]?key|access[_-]?token|auth[_-]?token|client[_-]?secret|password|passwd)\b["']?\s*[:=]\s*["']?[^\s"']{8,}["']?"#,
1479 "${1}=[REDACTED]",
1480 ),
1481];
1482
1483fn redaction_patterns() -> &'static Vec<(regex::Regex, &'static str)> {
1484 static PATTERNS: OnceLock<Vec<(regex::Regex, &'static str)>> = OnceLock::new();
1485 PATTERNS.get_or_init(|| {
1486 REDACTION_TABLE
1487 .iter()
1488 .map(|(pat, rep)| {
1489 (
1490 regex::Regex::new(pat).expect("redaction pattern must compile"),
1491 *rep,
1492 )
1493 })
1494 .collect()
1495 })
1496}
1497
1498pub(crate) fn redact_secrets(input: &str) -> String {
1502 let mut out = input.to_string();
1503 for (re, rep) in redaction_patterns() {
1504 if re.is_match(&out) {
1505 out = re.replace_all(&out, *rep).into_owned();
1506 }
1507 }
1508 out
1509}
1510
1511#[cfg(test)]
1516mod tests {
1517 use super::*;
1518
1519 const EST: TokenEstimation = TokenEstimation { chars_per_token: 4 };
1521 use serde_json::json;
1522 use std::sync::atomic::{AtomicUsize, Ordering};
1523 use std::sync::{Arc, Mutex};
1524
1525 fn sys(text: &str) -> Value {
1528 json!({"role": "system", "content": text})
1529 }
1530
1531 fn user(text: &str) -> Value {
1532 json!({"role": "user", "content": text})
1533 }
1534
1535 fn assistant_call(name: &str, args: Value) -> Value {
1536 json!({"role": "assistant", "content": "",
1537 "tool_calls": [{"function": {"name": name, "arguments": args}}]})
1538 }
1539
1540 fn tool_result(content: &str) -> Value {
1541 json!({"role": "tool", "content": content})
1542 }
1543
1544 fn tool_heavy(task: &str, rounds: usize, result_chars: usize) -> Vec<Value> {
1546 let mut msgs = vec![sys("you are newt"), user(task)];
1547 for i in 0..rounds {
1548 msgs.push(assistant_call(
1549 "read_file",
1550 json!({"path": format!("src/file_{i}.rs")}),
1551 ));
1552 msgs.push(tool_result(&format!("{i}:{}", "x".repeat(result_chars))));
1553 }
1554 msgs
1555 }
1556
1557 fn recording_summarizer(prompts: Arc<Mutex<Vec<String>>>, reply: &'static str) -> Summarizer {
1560 Box::new(move |prompt: String| {
1561 let prompts = prompts.clone();
1562 Box::pin(async move {
1563 prompts.lock().unwrap().push(prompt);
1564 Ok(reply.to_string())
1565 })
1566 })
1567 }
1568
1569 fn failing_summarizer(calls: Arc<AtomicUsize>) -> Summarizer {
1570 Box::new(move |_prompt: String| {
1571 let calls = calls.clone();
1572 Box::pin(async move {
1573 calls.fetch_add(1, Ordering::SeqCst);
1574 anyhow::bail!("summarizer endpoint 500")
1575 })
1576 })
1577 }
1578
1579 async fn run(
1582 messages: &[Value],
1583 budget: usize,
1584 max_messages: Option<usize>,
1585 summarizer: Option<&SummarizeFn>,
1586 state: &mut CompressState,
1587 ) -> CompressOutcome {
1588 compress(
1589 CompressRequest {
1590 messages,
1591 budget,
1592 max_messages,
1593 task: "fix the failing test",
1594 hard_budget: true,
1595 authoritative: true,
1596 focus: None,
1597 est: EST,
1598 summary_input_cap_floor_chars: 8_192,
1599 compaction_store: None,
1600 },
1601 summarizer,
1602 state,
1603 )
1604 .await
1605 }
1606
1607 async fn run_non_authoritative(
1611 messages: &[Value],
1612 budget: usize,
1613 max_messages: Option<usize>,
1614 summarizer: Option<&SummarizeFn>,
1615 state: &mut CompressState,
1616 ) -> CompressOutcome {
1617 compress(
1618 CompressRequest {
1619 messages,
1620 budget,
1621 max_messages,
1622 task: "fix the failing test",
1623 hard_budget: true,
1624 authoritative: false,
1625 focus: None,
1626 est: EST,
1627 summary_input_cap_floor_chars: 8_192,
1628 compaction_store: None,
1629 },
1630 summarizer,
1631 state,
1632 )
1633 .await
1634 }
1635
1636 async fn run_count_only(
1639 messages: &[Value],
1640 budget: usize,
1641 max_messages: Option<usize>,
1642 summarizer: Option<&SummarizeFn>,
1643 state: &mut CompressState,
1644 ) -> CompressOutcome {
1645 compress(
1646 CompressRequest {
1647 messages,
1648 budget,
1649 max_messages,
1650 task: "fix the failing test",
1651 hard_budget: false,
1652 authoritative: false,
1653 focus: None,
1654 est: EST,
1655 summary_input_cap_floor_chars: 8_192,
1656 compaction_store: None,
1657 },
1658 summarizer,
1659 state,
1660 )
1661 .await
1662 }
1663
1664 #[tokio::test]
1668 async fn within_budget_is_a_noop() {
1669 let msgs = tool_heavy("task", 2, 100);
1670 let mut state = CompressState::new();
1671 let out = run(&msgs, 100_000, None, None, &mut state).await;
1672 assert_eq!(out.action, CompressAction::Fit);
1673 assert!(!out.fired);
1674 assert_eq!(out.messages, msgs);
1675 assert_eq!(state.attempts, 0, "a no-op never counts as a compression");
1676 }
1677
1678 #[tokio::test]
1681 async fn prune_short_circuits_when_sufficient() {
1682 let big = "y".repeat(8_000);
1685 let mut msgs = vec![
1686 sys("you are newt"),
1687 user("task"),
1688 assistant_call("run_command", json!({"command": "cargo test"})),
1689 tool_result(&big),
1690 assistant_call("run_command", json!({"command": "cargo test"})),
1691 tool_result(&big),
1692 ];
1693 for i in 0..10 {
1694 msgs.push(user(&format!("filler {i}")));
1695 }
1696 let before = estimate_tokens(&msgs, EST);
1697 let budget = before - 1_000; let prompts = Arc::new(Mutex::new(Vec::new()));
1699 let s = recording_summarizer(prompts.clone(), "SUMMARY");
1700 let mut state = CompressState::new();
1701 let out = run(&msgs, budget, None, Some(&*s), &mut state).await;
1702 assert_eq!(out.action, CompressAction::Pruned);
1703 assert!(out.fired);
1704 assert!(out.tokens_after <= budget);
1705 assert_eq!(out.messages.len(), msgs.len(), "prune never drops messages");
1706 assert!(
1707 prompts.lock().unwrap().is_empty(),
1708 "summarizer must not be called when pruning suffices"
1709 );
1710 }
1711
1712 #[tokio::test]
1715 async fn summarizes_middle_with_markers_when_prune_insufficient() {
1716 let msgs = tool_heavy("ACTIVE TASK GAUNTLET-7f3d9c: do the thing", 6, 4_000);
1717 let before = estimate_tokens(&msgs, EST);
1718 let prompts = Arc::new(Mutex::new(Vec::new()));
1719 let s = recording_summarizer(prompts.clone(), "## Active Task\nGAUNTLET summary");
1720 let mut state = CompressState::new();
1721 let out = run(&msgs, before / 3, None, Some(&*s), &mut state).await;
1722
1723 assert_eq!(out.action, CompressAction::Summarized);
1724 assert!(out.fired);
1725 assert!(out.tokens_after < before);
1726 assert_eq!(out.messages[0], msgs[0]);
1728 assert_eq!(out.messages[1], msgs[1]);
1729 let summary = out.messages[2]["content"].as_str().unwrap();
1731 assert!(summary.starts_with(SUMMARY_PREFIX), "{summary}");
1732 assert!(summary.contains("GAUNTLET summary"), "{summary}");
1733 assert!(summary.contains(SUMMARY_END_MARKER), "{summary}");
1734 assert!(
1736 !out.messages.iter().any(|m| m["content"]
1737 .as_str()
1738 .is_some_and(|c| c.contains("earlier tool-call messages omitted"))),
1739 "the old placeholder-discard line must not appear"
1740 );
1741 }
1742
1743 #[tokio::test]
1751 async fn summarized_file_reads_get_a_reread_breadcrumb() {
1752 let sig = "pub fn connect(&self, url: &str, timeout: Duration) -> Result<Session, ConnErr>";
1753 let api_body = format!(
1754 "pub struct ApiClient;\nimpl ApiClient {{\n {sig} {{ todo!() }}\n}}\n{}",
1755 "// detail line\n".repeat(200)
1756 );
1757 let mut msgs = vec![
1758 sys("you are newt, a coding agent"),
1759 user("ACTIVE TASK: implement reconnect() on ApiClient using its connect() method"),
1760 assistant_call("read_file", json!({ "path": "src/api.rs" })),
1761 tool_result(&api_body), ];
1763 for i in 0..8 {
1766 msgs.push(assistant_call(
1767 "read_file",
1768 json!({ "path": format!("src/other_{i}.rs") }),
1769 ));
1770 msgs.push(tool_result(&format!(
1771 "// other file {i}\n{}",
1772 "filler line\n".repeat(150)
1773 )));
1774 }
1775 let before = estimate_tokens(&msgs, EST);
1776 let prompts = Arc::new(Mutex::new(Vec::new()));
1777 let s = recording_summarizer(
1779 prompts.clone(),
1780 "## Active Task\nImplement reconnect(). The agent earlier read src/api.rs \
1781 (defines ApiClient) and several other files.",
1782 );
1783 let mut state = CompressState::new();
1784 let out = run(&msgs, before / 2, None, Some(&*s), &mut state).await;
1785
1786 let assembled: String = out
1787 .messages
1788 .iter()
1789 .filter_map(|m| m["content"].as_str())
1790 .collect::<Vec<_>>()
1791 .join("\n");
1792 eprintln!(
1793 "#319: fired={} action={:?}\n{}",
1794 out.fired,
1795 out.action,
1796 &assembled[..assembled.len().min(1200)]
1797 );
1798 assert!(out.fired && out.action == CompressAction::Summarized);
1800 assert!(
1803 assembled.contains("src/api.rs"),
1804 "the dropped file must be named so the model knows to re-read it"
1805 );
1806 assert!(
1807 assembled.contains("RE-READ") && assembled.contains("do NOT recall"),
1808 "the breadcrumb must carry the re-read / don't-recall directive"
1809 );
1810 }
1811
1812 #[tokio::test]
1815 async fn summary_request_carries_task_verbatim_and_template() {
1816 let task = "ACTIVE TASK GAUNTLET-7f3d9c: read ten files then report";
1817 let mut msgs = tool_heavy(task, 6, 4_000);
1818 msgs[1] = user(task);
1819 let before = estimate_tokens(&msgs, EST);
1820 let prompts = Arc::new(Mutex::new(Vec::new()));
1821 let s = recording_summarizer(prompts.clone(), "SUMMARY");
1822 let mut state = CompressState::new();
1823 let out = compress(
1824 CompressRequest {
1825 messages: &msgs,
1826 budget: before / 3,
1827 max_messages: None,
1828 task,
1829 hard_budget: true,
1830 authoritative: true,
1831 focus: None,
1832 est: EST,
1833 summary_input_cap_floor_chars: 8_192,
1834 compaction_store: None,
1835 },
1836 Some(&*s),
1837 &mut state,
1838 )
1839 .await;
1840 assert_eq!(out.action, CompressAction::Summarized);
1841
1842 let prompts = prompts.lock().unwrap();
1843 assert_eq!(prompts.len(), 1);
1844 let p = &prompts[0];
1845 assert!(p.contains(task), "original task must appear verbatim: {p}");
1846 for section in [
1847 "## Active Task",
1848 "## Completed Actions",
1849 "## In Progress",
1850 "## Key Decisions",
1851 "## Relevant Files",
1852 "## Critical Context",
1853 ] {
1854 assert!(p.contains(section), "missing template section {section}");
1855 }
1856 assert!(p.contains("copied verbatim"), "verbatim-Active-Task rule");
1857 assert!(p.contains("[REDACTED]"), "redaction preamble present");
1858 }
1859
1860 #[tokio::test]
1862 async fn no_summarizer_uses_static_fallback_marker() {
1863 let msgs = tool_heavy("task", 6, 4_000);
1864 let before = estimate_tokens(&msgs, EST);
1865 let mut state = CompressState::new();
1866 let out = run(&msgs, before / 3, None, None, &mut state).await;
1867 assert_eq!(out.action, CompressAction::StaticFallback);
1868 let summary = out.messages[2]["content"].as_str().unwrap();
1869 assert!(summary.starts_with(SUMMARY_PREFIX), "{summary}");
1870 assert!(summary.contains(SUMMARY_END_MARKER), "{summary}");
1871 let removed = msgs.len() - (out.messages.len() - 1);
1874 assert!(
1875 summary.contains(&format!(
1876 "Summary generation was unavailable. {removed} message(s) were removed."
1877 )),
1878 "{summary}"
1879 );
1880 }
1881
1882 #[tokio::test]
1884 async fn summarizer_failure_falls_back_to_static_marker() {
1885 let msgs = tool_heavy("task", 6, 4_000);
1886 let before = estimate_tokens(&msgs, EST);
1887 let calls = Arc::new(AtomicUsize::new(0));
1888 let s = failing_summarizer(calls.clone());
1889 let mut state = CompressState::new();
1890 let out = run(&msgs, before / 3, None, Some(&*s), &mut state).await;
1891 assert_eq!(calls.load(Ordering::SeqCst), 1, "summarizer was attempted");
1892 assert_eq!(out.action, CompressAction::StaticFallback);
1893 let summary = out.messages[2]["content"].as_str().unwrap();
1894 assert!(summary.contains("Summary generation was unavailable."));
1895 }
1896
1897 #[tokio::test]
1899 async fn empty_summary_falls_back_to_static_marker() {
1900 let msgs = tool_heavy("task", 6, 4_000);
1901 let before = estimate_tokens(&msgs, EST);
1902 let prompts = Arc::new(Mutex::new(Vec::new()));
1903 let s = recording_summarizer(prompts.clone(), " \n ");
1904 let mut state = CompressState::new();
1905 let out = run(&msgs, before / 3, None, Some(&*s), &mut state).await;
1906 assert_eq!(out.action, CompressAction::StaticFallback);
1907 }
1908
1909 #[tokio::test]
1914 async fn giant_aged_round_is_pruned_aggressively_not_shipped_over_budget() {
1915 let task = "ACTIVE TASK GAUNTLET-7f3d9c: summarize the three files";
1916 let mut msgs = vec![sys("you are newt"), user(task)];
1917 msgs.push(json!({"role": "assistant", "content": "", "tool_calls": [
1918 {"function": {"name": "read_file", "arguments": {"path": "a.txt"}}},
1919 {"function": {"name": "read_file", "arguments": {"path": "b.txt"}}},
1920 {"function": {"name": "read_file", "arguments": {"path": "c.txt"}}},
1921 ]}));
1922 for _ in 0..3 {
1923 msgs.push(tool_result(&"z".repeat(50_000))); }
1925 msgs.push(assistant_call("read_file", json!({"path": "d.txt"})));
1927 msgs.push(tool_result("short fresh result"));
1928 let mut state = CompressState::new();
1929 let out = run(&msgs, 3_000, None, None, &mut state).await;
1930 assert!(
1931 out.tokens_after <= 3_000,
1932 "the fit pass must bring ~{} under budget, got {}",
1933 out.tokens_before,
1934 out.tokens_after
1935 );
1936 assert!(out.fired);
1937 assert!(out
1939 .messages
1940 .iter()
1941 .any(|m| m["content"].as_str() == Some(task)));
1942 assert_eq!(out.messages[2]["tool_calls"].as_array().unwrap().len(), 3);
1944 assert_eq!(
1945 out.messages
1946 .iter()
1947 .filter(|m| m["role"].as_str() == Some("tool"))
1948 .count(),
1949 4
1950 );
1951 assert_eq!(
1953 out.messages.last().unwrap()["content"].as_str(),
1954 Some("short fresh result")
1955 );
1956 }
1957
1958 #[tokio::test]
1967 async fn fresh_trailing_tool_group_survives_the_aggressive_pass() {
1968 let task = "ACTIVE TASK GAUNTLET-7f3d9c: summarize the three files";
1969 let big = "z".repeat(50_000);
1970 let mut msgs = vec![sys("you are newt"), user(task)];
1971 msgs.push(json!({"role": "assistant", "content": "", "tool_calls": [
1972 {"function": {"name": "read_file", "arguments": {"path": "a.txt"}}},
1973 {"function": {"name": "read_file", "arguments": {"path": "b.txt"}}},
1974 {"function": {"name": "read_file", "arguments": {"path": "c.txt"}}},
1975 ]}));
1976 for _ in 0..3 {
1977 msgs.push(tool_result(&big));
1978 }
1979 let mut state = CompressState::new();
1980 let out = run_count_only(&msgs, 3_000, None, None, &mut state).await;
1981 let results: Vec<&str> = out
1985 .messages
1986 .iter()
1987 .filter(|m| m["role"].as_str() == Some("tool"))
1988 .map(|m| m["content"].as_str().unwrap())
1989 .collect();
1990 assert_eq!(results.len(), 3);
1991 for r in results {
1992 assert_eq!(r, big, "fresh trailing tool results must never be pruned");
1993 }
1994 assert!(
1995 out.tokens_after > 3_000,
1996 "this shape is genuinely incompressible without destroying fresh results"
1997 );
1998 }
1999
2000 #[test]
2008 fn trailing_group_derivation_survives_interleaved_messages() {
2009 let mut msgs = vec![sys("you are newt"), user("task")];
2010 msgs.push(json!({"role": "assistant", "content": "", "tool_calls": [
2011 {"function": {"name": "read_file", "arguments": {"path": "a.rs"}}},
2012 {"function": {"name": "read_file", "arguments": {"path": "b.rs"}}},
2013 ]}));
2014 msgs.push(tool_result("result a"));
2015 msgs.push(tool_result("result b"));
2016 assert_eq!(trailing_tool_group_len(&msgs), 3);
2018 msgs.push(user(
2021 "[3 consecutive read-only rounds with no file writes.]",
2022 ));
2023 assert_eq!(trailing_tool_group_len(&msgs), 4);
2024 msgs.push(summary_message("reference summary"));
2026 assert_eq!(trailing_tool_group_len(&msgs), 5);
2027 msgs.push(json!({"role": "assistant", "content": "thinking…"}));
2029 assert_eq!(trailing_tool_group_len(&msgs), 6);
2030 assert_eq!(trailing_tool_group_len(&[sys("s"), user("t")]), 0);
2032 let roleless = vec![
2035 user("task"),
2036 json!({"content": "", "tool_calls": [
2037 {"function": {"name": "read_file", "arguments": {"path": "a"}}}]}),
2038 tool_result("result a"),
2039 ];
2040 assert_eq!(trailing_tool_group_len(&roleless), 2);
2041 }
2042
2043 #[tokio::test]
2051 async fn nudge_after_fresh_group_does_not_defeat_the_protection() {
2052 let task = "ACTIVE TASK GAUNTLET-7f3d9c: read both files then report";
2053 let unseen1 = format!("1:{}", "u".repeat(8_000));
2054 let unseen2 = format!("2:{}", "v".repeat(8_000));
2055 let mut msgs = vec![sys("you are newt"), user(task)];
2056 for i in 0..6 {
2058 msgs.push(assistant_call(
2059 "read_file",
2060 json!({"path": format!("aged_{i}.rs")}),
2061 ));
2062 msgs.push(tool_result(&format!("{i}:{}", "a".repeat(8_000))));
2063 }
2064 msgs.push(json!({"role": "assistant", "content": "", "tool_calls": [
2066 {"function": {"name": "read_file", "arguments": {"path": "unseen1.rs"}}},
2067 {"function": {"name": "read_file", "arguments": {"path": "unseen2.rs"}}},
2068 ]}));
2069 msgs.push(tool_result(&unseen1));
2070 msgs.push(tool_result(&unseen2));
2071 msgs.push(user(
2073 "[3 consecutive read-only rounds with no file writes. \
2074 Stop exploring. Call edit_file or write_file now.]",
2075 ));
2076 let mut state = CompressState::new();
2077 let out = run_count_only(&msgs, 2_000, None, None, &mut state).await;
2081 assert!(out.fired);
2082 let tool_contents: Vec<&str> = out
2083 .messages
2084 .iter()
2085 .filter(|m| m["role"].as_str() == Some("tool"))
2086 .map(|m| m["content"].as_str().unwrap())
2087 .collect();
2088 assert!(
2089 tool_contents.contains(&unseen1.as_str()),
2090 "#270: UNSEEN1 must survive the nudge-truncated derivation \
2091 (got tool contents {:?})",
2092 tool_contents
2093 .iter()
2094 .map(|c| c.chars().take(40).collect::<String>())
2095 .collect::<Vec<_>>()
2096 );
2097 assert!(
2098 tool_contents.contains(&unseen2.as_str()),
2099 "UNSEEN2 must survive too"
2100 );
2101 assert!(out.messages.iter().any(|m| m["content"]
2103 .as_str()
2104 .is_some_and(|c| c.contains("read-only rounds"))));
2105 println!(
2106 "#270 repro trace: {} -> {} est. tokens (target {}), group intact",
2107 out.tokens_before, out.tokens_after, 2_000
2108 );
2109 }
2110
2111 #[tokio::test]
2114 async fn compaction_notice_after_fresh_group_does_not_defeat_the_protection() {
2115 let task = "ACTIVE TASK GAUNTLET-7f3d9c: read both files then report";
2116 let unseen1 = format!("1:{}", "u".repeat(8_000));
2117 let unseen2 = format!("2:{}", "v".repeat(8_000));
2118 let mut msgs = vec![sys("you are newt"), user(task)];
2119 for i in 0..6 {
2120 msgs.push(assistant_call(
2121 "read_file",
2122 json!({"path": format!("aged_{i}.rs")}),
2123 ));
2124 msgs.push(tool_result(&format!("{i}:{}", "a".repeat(8_000))));
2125 }
2126 msgs.push(json!({"role": "assistant", "content": "", "tool_calls": [
2127 {"function": {"name": "read_file", "arguments": {"path": "unseen1.rs"}}},
2128 {"function": {"name": "read_file", "arguments": {"path": "unseen2.rs"}}},
2129 ]}));
2130 msgs.push(tool_result(&unseen1));
2131 msgs.push(tool_result(&unseen2));
2132 msgs.push(summary_message("## Active Task\nreference summary"));
2133 let mut state = CompressState::new();
2134 let out = run_count_only(&msgs, 2_000, None, None, &mut state).await;
2135 let tool_contents: Vec<&str> = out
2136 .messages
2137 .iter()
2138 .filter(|m| m["role"].as_str() == Some("tool"))
2139 .map(|m| m["content"].as_str().unwrap())
2140 .collect();
2141 assert!(tool_contents.contains(&unseen1.as_str()), "UNSEEN1 intact");
2142 assert!(tool_contents.contains(&unseen2.as_str()), "UNSEEN2 intact");
2143 }
2144
2145 #[test]
2150 fn within_group_reclaim_fires_only_when_group_alone_exceeds() {
2151 let big = "z".repeat(20_000); let small = "s".repeat(1_200); let group = |contents: &[&str]| -> Vec<Value> {
2154 let mut msgs = vec![sys("you are newt"), user("task")];
2155 msgs.push(json!({"role": "assistant", "content": "", "tool_calls":
2156 contents.iter().enumerate().map(|(i, _)| json!(
2157 {"function": {"name": "read_file",
2158 "arguments": {"path": format!("f{i}.txt")}}}
2159 )).collect::<Vec<_>>()
2160 }));
2161 msgs.extend(contents.iter().map(|c| tool_result(c)));
2162 msgs
2163 };
2164
2165 let mut fits = group(&[&small, &small, &small]);
2167 let before = fits.clone();
2168 assert!(!reclaim_within_trailing_group(&mut fits, 10_000, EST));
2169 assert_eq!(fits, before, "a group within its share is never touched");
2170
2171 let mut no_group = vec![sys("s"), user(&big)];
2173 assert!(!reclaim_within_trailing_group(&mut no_group, 100, EST));
2174
2175 let mut single = group(&[&big]);
2179 let before = single.clone();
2180 assert!(!reclaim_within_trailing_group(&mut single, 1_000, EST));
2181 assert_eq!(single, before);
2182
2183 let mut early = group(&[&big, &small, &small]);
2186 assert!(reclaim_within_trailing_group(&mut early, 1_500, EST));
2187 let results: Vec<&str> = early
2188 .iter()
2189 .filter(|m| m["role"].as_str() == Some("tool"))
2190 .map(|m| m["content"].as_str().unwrap())
2191 .collect();
2192 assert!(
2193 results[0].starts_with("[read_file] read 'f0.txt'"),
2194 "oldest one-lined with the re-read affordance: {}",
2195 results[0]
2196 );
2197 assert_eq!(results[1], small, "middle untouched after early stop");
2198 assert_eq!(results[2], small, "newest untouched");
2199 assert!(estimate_tokens(&early, EST) <= 1_500, "the list now fits");
2200
2201 let mut residual = group(&[&small, &small, &big]);
2204 assert!(reclaim_within_trailing_group(&mut residual, 1_000, EST));
2205 let results: Vec<&str> = residual
2206 .iter()
2207 .filter(|m| m["role"].as_str() == Some("tool"))
2208 .map(|m| m["content"].as_str().unwrap())
2209 .collect();
2210 assert!(results[0].starts_with("[read_file] read 'f0.txt'"));
2211 assert!(results[1].starts_with("[read_file] read 'f1.txt'"));
2212 assert_eq!(results[2], big, "the newest member is never a candidate");
2213 assert!(
2214 estimate_tokens(&residual, EST) > 1_000,
2215 "single-result-too-big: truthfully still over budget"
2216 );
2217 }
2218
2219 #[tokio::test]
2227 async fn oversized_group_reclaims_within_keeping_newest_whole() {
2228 let task = "ACTIVE TASK GAUNTLET-7f3d9c: summarize the three files";
2229 let big = "z".repeat(50_000); let mut msgs = vec![sys("you are newt"), user(task)];
2231 msgs.push(json!({"role": "assistant", "content": "", "tool_calls": [
2232 {"function": {"name": "read_file", "arguments": {"path": "a.txt"}}},
2233 {"function": {"name": "read_file", "arguments": {"path": "b.txt"}}},
2234 {"function": {"name": "read_file", "arguments": {"path": "c.txt"}}},
2235 ]}));
2236 for _ in 0..3 {
2237 msgs.push(tool_result(&big));
2238 }
2239 let mut state = CompressState::new();
2240 let out = run(&msgs, 3_000, None, None, &mut state).await;
2241 assert!(out.fired);
2242 let results: Vec<&str> = out
2243 .messages
2244 .iter()
2245 .filter(|m| m["role"].as_str() == Some("tool"))
2246 .map(|m| m["content"].as_str().unwrap())
2247 .collect();
2248 assert_eq!(results.len(), 3, "pairing intact — nothing dropped");
2249 assert!(
2250 results[0].starts_with("[read_file] read 'a.txt'"),
2251 "oldest one-lined, file named for re-read: {}",
2252 results[0]
2253 );
2254 assert!(
2255 results[1].starts_with("[read_file] read 'b.txt'"),
2256 "older one-lined in order: {}",
2257 results[1]
2258 );
2259 assert_eq!(results[2], big, "newest result reaches the model whole");
2260 assert!(out
2262 .messages
2263 .iter()
2264 .any(|m| m["content"].as_str() == Some(task)));
2265 assert!(out.tokens_after > 3_000);
2268 assert!(
2269 out.tokens_after < out.tokens_before / 2,
2270 "but the reclaim was real: {} -> {}",
2271 out.tokens_before,
2272 out.tokens_after
2273 );
2274 println!(
2275 "#285 scenario trace: {} -> {} est. tokens (budget 3000), \
2276 a/b one-lined, c whole",
2277 out.tokens_before, out.tokens_after
2278 );
2279 }
2280
2281 #[tokio::test]
2285 async fn under_budget_group_is_untouched_under_hard_pressure() {
2286 let task = "ACTIVE TASK GAUNTLET-7f3d9c: read both files then report";
2287 let unseen1 = format!("1:{}", "u".repeat(8_000)); let unseen2 = format!("2:{}", "v".repeat(8_000));
2289 let mut msgs = vec![sys("you are newt"), user(task)];
2290 for i in 0..6 {
2291 msgs.push(assistant_call(
2292 "read_file",
2293 json!({"path": format!("aged_{i}.rs")}),
2294 ));
2295 msgs.push(tool_result(&format!("{i}:{}", "a".repeat(8_000))));
2296 }
2297 msgs.push(json!({"role": "assistant", "content": "", "tool_calls": [
2298 {"function": {"name": "read_file", "arguments": {"path": "unseen1.rs"}}},
2299 {"function": {"name": "read_file", "arguments": {"path": "unseen2.rs"}}},
2300 ]}));
2301 msgs.push(tool_result(&unseen1));
2302 msgs.push(tool_result(&unseen2));
2303 msgs.push(user(
2304 "[3 consecutive read-only rounds with no file writes.]",
2305 ));
2306 let mut state = CompressState::new();
2307 let out = run(&msgs, 6_000, None, None, &mut state).await;
2310 assert!(out.fired);
2311 assert!(
2312 out.tokens_after <= 6_000,
2313 "must land under the hard budget ({} -> {})",
2314 out.tokens_before,
2315 out.tokens_after
2316 );
2317 let tool_contents: Vec<&str> = out
2318 .messages
2319 .iter()
2320 .filter(|m| m["role"].as_str() == Some("tool"))
2321 .map(|m| m["content"].as_str().unwrap())
2322 .collect();
2323 assert!(tool_contents.contains(&unseen1.as_str()), "UNSEEN1 whole");
2324 assert!(tool_contents.contains(&unseen2.as_str()), "UNSEEN2 whole");
2325 }
2326
2327 #[tokio::test]
2330 async fn max_messages_forces_summary_stage() {
2331 let msgs = tool_heavy("task", 8, 50); let before = estimate_tokens(&msgs, EST);
2333 let prompts = Arc::new(Mutex::new(Vec::new()));
2334 let s = recording_summarizer(prompts.clone(), "SUMMARY");
2335 let mut state = CompressState::new();
2336 let out = run_count_only(&msgs, before + 1_000, Some(8), Some(&*s), &mut state).await;
2337 assert_eq!(out.action, CompressAction::Summarized);
2338 assert!(out.messages.len() < msgs.len());
2339 }
2340
2341 #[tokio::test]
2347 async fn second_compression_still_shrinks_and_keeps_fresh_results() {
2348 let fresh = format!("9:{}", "x".repeat(4_000));
2349 let msgs = tool_heavy("fix the failing test", 10, 4_000);
2350 let prompts = Arc::new(Mutex::new(Vec::new()));
2351 let s = recording_summarizer(prompts.clone(), "SUMMARY ONE");
2352 let mut state = CompressState::new();
2353 let budget = estimate_tokens(&msgs, EST) / 2;
2354 let first = run_count_only(&msgs, budget, Some(8), Some(&*s), &mut state).await;
2355 assert!(first.messages.len() < msgs.len(), "first pass shrinks");
2356 assert!(first.messages.iter().any(is_compaction_message));
2357
2358 let mut grown = first.messages.clone();
2360 for i in 10..16 {
2361 grown.push(assistant_call(
2362 "read_file",
2363 json!({"path": format!("src/file_{i}.rs")}),
2364 ));
2365 grown.push(tool_result(&format!("{i}:{}", "x".repeat(4_000))));
2366 }
2367 let grown_fresh = grown.last().unwrap()["content"]
2368 .as_str()
2369 .unwrap()
2370 .to_string();
2371 let budget2 = estimate_tokens(&grown, EST) / 2;
2372 let second = run_count_only(&grown, budget2, Some(8), Some(&*s), &mut state).await;
2373 assert!(
2374 second.messages.len() < grown.len(),
2375 "second compression must still shrink ({} -> {})",
2376 grown.len(),
2377 second.messages.len()
2378 );
2379 assert!(
2380 second.messages.len() <= 10,
2381 "count goal must stay reachable, got {}",
2382 second.messages.len()
2383 );
2384 assert_eq!(
2386 first.messages.last().unwrap()["content"].as_str(),
2387 Some(fresh.as_str()),
2388 "first pass fresh result intact"
2389 );
2390 assert_eq!(
2391 second.messages.last().unwrap()["content"].as_str(),
2392 Some(grown_fresh.as_str()),
2393 "second pass fresh result intact"
2394 );
2395 assert!(!state.disabled);
2397 assert_eq!(state.attempts, 0);
2398 }
2399
2400 #[tokio::test]
2404 async fn count_only_never_feeds_or_consults_anti_thrash() {
2405 let mut msgs = vec![sys("you are newt"), user("task")];
2408 for i in 0..10 {
2409 msgs.push(user(&format!("note {i}")));
2410 }
2411 let mut state = CompressState::new();
2412 for _ in 0..4 {
2413 let budget = estimate_tokens(&msgs, EST) / 2;
2414 let out = run_count_only(&msgs, budget, Some(6), None, &mut state).await;
2415 assert_ne!(out.action, CompressAction::Refused);
2416 }
2417 assert!(!state.disabled, "count-only passes must never latch");
2418 assert_eq!(state.attempts, 0, "count-only passes must never record");
2419
2420 let mut latched = CompressState::new();
2422 latched.disabled = true;
2423 latched.notified = true;
2424 let budget = estimate_tokens(&msgs, EST) / 2;
2425 let out = run_count_only(&msgs, budget, Some(6), None, &mut latched).await;
2426 assert_ne!(out.action, CompressAction::Refused);
2427 assert!(
2428 out.messages.len() < msgs.len(),
2429 "the VRAM guard must stay alive while anti-thrash is latched"
2430 );
2431 }
2432
2433 #[test]
2436 fn boundary_head_is_system_plus_original_task() {
2437 let msgs = tool_heavy("the task", 6, 1_000);
2438 let b = compute_boundary(&msgs, 1_000, None, EST);
2439 assert_eq!(b.head, 2, "system + original task");
2440
2441 let mut msgs2 = vec![sys("a"), sys("b"), user("task"), user("more")];
2443 msgs2.extend(tool_heavy("x", 4, 1_000).split_off(2));
2444 assert_eq!(compute_boundary(&msgs2, 1_000, None, EST).head, 3);
2445 }
2446
2447 #[test]
2448 fn boundary_tail_is_token_budgeted_with_minimum() {
2449 let msgs = tool_heavy("task", 10, 1_000);
2451 let b = compute_boundary(&msgs, 4_000, None, EST);
2452 let tail_tokens: usize = msgs[b.tail_start..]
2453 .iter()
2454 .map(|m| estimate_value_tokens(m, EST))
2455 .sum();
2456 assert!(
2457 tail_tokens <= 1_500,
2458 "tail stays near the token budget, got {tail_tokens}"
2459 );
2460 assert!(
2461 msgs.len() - b.tail_start >= TAIL_MIN_MESSAGES,
2462 "at least the minimum tail"
2463 );
2464 assert!(b.tail_start > b.head, "a middle exists to summarize");
2465
2466 let msgs = tool_heavy("task", 6, 40_000);
2468 let b = compute_boundary(&msgs, 4_000, None, EST);
2469 assert!(msgs.len() - b.tail_start >= TAIL_MIN_MESSAGES);
2470 }
2471
2472 #[test]
2473 fn boundary_anchors_last_user_message_into_tail() {
2474 let mut msgs = tool_heavy("task", 2, 500);
2477 msgs.push(user("IMPORTANT FOLLOW-UP: also update the docs"));
2478 let follow_up = msgs.len() - 1;
2479 for i in 0..6 {
2480 msgs.push(assistant_call(
2481 "read_file",
2482 json!({"path": format!("f{i}")}),
2483 ));
2484 msgs.push(tool_result(&"q".repeat(4_000)));
2485 }
2486 let b = compute_boundary(&msgs, 2_000, None, EST);
2487 assert!(
2488 b.tail_start <= follow_up,
2489 "tail (start {}) must include the last user message at {follow_up}",
2490 b.tail_start
2491 );
2492 }
2493
2494 #[test]
2498 fn boundary_anchor_skips_compaction_messages() {
2499 let mut msgs = vec![sys("you are newt"), user("the task")];
2500 msgs.push(summary_message("## Active Task\nthe task (summarized)"));
2501 for i in 0..6 {
2502 msgs.push(assistant_call(
2503 "read_file",
2504 json!({"path": format!("f{i}")}),
2505 ));
2506 msgs.push(tool_result(&"q".repeat(4_000)));
2507 }
2508 let b = compute_boundary(&msgs, 2_000, None, EST);
2509 assert!(
2510 b.tail_start > 2,
2511 "the tail must not pin to the compaction message at index 2 \
2512 (tail_start {})",
2513 b.tail_start
2514 );
2515 let mut msgs2 = msgs.clone();
2517 msgs2.push(user("IMPORTANT FOLLOW-UP: also update the docs"));
2518 let follow_up = msgs2.len() - 1;
2519 for _ in 0..4 {
2520 msgs2.push(assistant_call("read_file", json!({"path": "g"})));
2521 msgs2.push(tool_result(&"q".repeat(4_000)));
2522 }
2523 let b2 = compute_boundary(&msgs2, 2_000, None, EST);
2524 assert!(
2525 b2.tail_start <= follow_up,
2526 "a real user message still anchors the tail"
2527 );
2528 }
2529
2530 #[test]
2535 fn boundary_count_cap_holds_after_the_anchor() {
2536 let mut msgs = vec![
2537 sys("you are newt"),
2538 user("turn 1"),
2539 json!({"role": "assistant", "content": "reply 1"}),
2540 user("turn 2"),
2541 json!({"role": "assistant", "content": "reply 2"}),
2542 user("the current task"),
2543 ];
2544 let task_idx = msgs.len() - 1;
2545 for i in 0..12 {
2546 msgs.push(assistant_call(
2547 "read_file",
2548 json!({"path": format!("f{i}")}),
2549 ));
2550 msgs.push(tool_result(&"q".repeat(2_000)));
2551 }
2552 let b = compute_boundary(&msgs, 4_000, Some(10), EST);
2553 let assembled = b.head + 1 + (msgs.len() - b.tail_start);
2554 assert!(
2555 assembled <= 12,
2556 "the anchor must not defeat the count goal (assembled {assembled})"
2557 );
2558 assert!(
2559 b.tail_start > task_idx,
2560 "the cut advanced past the deep anchor (tail_start {})",
2561 b.tail_start
2562 );
2563 let b_token = compute_boundary(&msgs, 4_000, None, EST);
2565 assert!(b_token.tail_start <= task_idx);
2566 }
2567
2568 #[test]
2569 fn boundary_never_splits_a_tool_pair() {
2570 for budget in [1_000usize, 2_000, 4_000, 8_000, 16_000] {
2571 let msgs = tool_heavy("task", 8, 2_000);
2572 let b = compute_boundary(&msgs, budget, None, EST);
2573 assert_ne!(
2574 msgs[b.tail_start]["role"].as_str(),
2575 Some("tool"),
2576 "budget {budget}: tail must not start inside a result group"
2577 );
2578 }
2579 }
2580
2581 #[tokio::test]
2584 async fn compress_output_has_no_orphan_tool_pairs() {
2585 let msgs = tool_heavy("task", 8, 2_000);
2586 let mut state = CompressState::new();
2587 let out = run(&msgs, 2_500, None, None, &mut state).await;
2588 let m = &out.messages;
2591 for (i, msg) in m.iter().enumerate() {
2592 if let Some(tcs) = msg["tool_calls"].as_array() {
2593 let mut following = 0;
2594 for next in &m[i + 1..] {
2595 if next["role"].as_str() == Some("tool") {
2596 following += 1;
2597 } else {
2598 break;
2599 }
2600 }
2601 assert_eq!(
2602 following,
2603 tcs.len(),
2604 "message {i}: {} tool_calls need {} contiguous results",
2605 tcs.len(),
2606 tcs.len()
2607 );
2608 }
2609 }
2610 }
2611
2612 #[tokio::test]
2617 async fn anti_thrash_disables_notifies_once_then_refuses() {
2618 let mut msgs = vec![sys(&"s".repeat(4_000)), user("task")];
2621 for i in 0..3 {
2622 msgs.push(user(&format!("note {i}")));
2623 }
2624 let mut state = CompressState::new();
2625
2626 let first = run(&msgs, 100, None, None, &mut state).await;
2627 assert_ne!(first.action, CompressAction::Refused);
2628 assert!(first.notice.is_none(), "one poor pass is not yet thrash");
2629
2630 let second = run(&msgs, 100, None, None, &mut state).await;
2631 let notice = second.notice.expect("second poor pass must notify");
2632 assert!(notice.contains("disabled for this session"), "{notice}");
2633
2634 let third = run(&msgs, 100, None, None, &mut state).await;
2635 assert_eq!(third.action, CompressAction::Refused);
2636 assert!(!third.fired);
2637 assert!(
2638 third.notice.is_none(),
2639 "the notice must be delivered exactly once"
2640 );
2641
2642 let ok = run(&msgs, 100_000, None, None, &mut state).await;
2644 assert_eq!(ok.action, CompressAction::Fit);
2645 }
2646
2647 #[tokio::test]
2655 async fn non_authoritative_budget_fails_open_instead_of_refusing() {
2656 let mut msgs = vec![sys(&"s".repeat(4_000)), user("task")];
2657 for i in 0..3 {
2658 msgs.push(user(&format!("note {i}")));
2659 }
2660 let mut state = CompressState::new();
2661
2662 let first = run_non_authoritative(&msgs, 100, None, None, &mut state).await;
2665 assert_ne!(first.action, CompressAction::Refused);
2666 let _second = run_non_authoritative(&msgs, 100, None, None, &mut state).await;
2667 assert!(state.disabled, "two poor passes must latch the breaker");
2668
2669 let third = run_non_authoritative(&msgs, 100, None, None, &mut state).await;
2671 assert_eq!(third.action, CompressAction::DispatchedOverBudget);
2672 assert!(!third.fired, "messages pass through unchanged");
2673 assert_eq!(third.messages.len(), msgs.len(), "nothing dropped");
2674 let notice = third.notice.expect("fail-open is surfaced once");
2675 assert!(notice.contains("no authoritative window"), "{notice}");
2676
2677 let fourth = run_non_authoritative(&msgs, 100, None, None, &mut state).await;
2679 assert_eq!(fourth.action, CompressAction::DispatchedOverBudget);
2680 assert!(fourth.notice.is_none(), "notice delivered exactly once");
2681 }
2682
2683 #[tokio::test]
2687 async fn authoritative_budget_still_refuses_when_latched() {
2688 let mut msgs = vec![sys(&"s".repeat(4_000)), user("task")];
2689 for i in 0..3 {
2690 msgs.push(user(&format!("note {i}")));
2691 }
2692 let mut state = CompressState::new();
2693 run(&msgs, 100, None, None, &mut state).await;
2694 run(&msgs, 100, None, None, &mut state).await;
2695 assert!(state.disabled);
2696 let third = run(&msgs, 100, None, None, &mut state).await;
2697 assert_eq!(
2698 third.action,
2699 CompressAction::Refused,
2700 "an authoritative ceiling must still refuse, not truncate"
2701 );
2702 }
2703
2704 #[tokio::test]
2710 async fn latched_authoritative_compacts_to_marker_instead_of_refusing() {
2711 let mut msgs = vec![sys("sys"), user("task")];
2712 for i in 0..24 {
2713 msgs.push(user(&format!("middle note {i} {}", "m".repeat(200))));
2714 }
2715 msgs.push(user("recent tail"));
2716 let mut state = CompressState::new();
2717 state.latch_disabled_for_tests();
2718 let budget = 300; let out = run(&msgs, budget, None, None, &mut state).await;
2720 assert_ne!(
2721 out.action,
2722 CompressAction::Refused,
2723 "a reducible middle must compact to a marker, not dead-end"
2724 );
2725 assert!(
2726 out.tokens_after <= budget,
2727 "forced marker compaction must fit the budget ({} > {budget})",
2728 out.tokens_after
2729 );
2730 assert!(out.fired, "the marker compaction changed the working set");
2731 }
2732
2733 #[tokio::test]
2734 async fn compaction_store_captures_redacted_span_and_names_the_handle() {
2735 use crate::agentic::spill::{SessionSpillStore, SpillStore};
2736 let compaction = SessionSpillStore::default();
2740 let mut msgs = vec![sys("sys"), user("task")];
2741 msgs.push(user("config api_key=9f8e7d6c5b4a32100ffee and more"));
2743 for i in 0..24 {
2744 msgs.push(user(&format!("middle note {i} {}", "m".repeat(200))));
2745 }
2746 msgs.push(user("recent tail"));
2747 let mut state = CompressState::new();
2748 let out = compress(
2749 CompressRequest {
2750 messages: &msgs,
2751 budget: 300,
2752 max_messages: None,
2753 task: "task",
2754 hard_budget: true,
2755 authoritative: true,
2756 focus: None,
2757 est: EST,
2758 summary_input_cap_floor_chars: 8_192,
2759 compaction_store: Some(&compaction),
2760 },
2761 None, &mut state,
2763 )
2764 .await;
2765 assert!(out.fired);
2766 assert!(
2768 out.messages.iter().any(|m| m["content"]
2769 .as_str()
2770 .is_some_and(|c| c.contains("compaction:s0"))),
2771 "the marker must name the compaction handle"
2772 );
2773 let span = compaction.fetch("s0").expect("span must be stored");
2775 assert!(
2776 !span.contains("9f8e7d6c5b4a32100ffee"),
2777 "the secret must be redacted before store: {span}"
2778 );
2779 assert!(
2780 span.contains("[REDACTED]"),
2781 "redaction marker present: {span}"
2782 );
2783 }
2784
2785 #[tokio::test]
2786 async fn knowledge_base_stable_base_survives_compression() {
2787 let kb = "## Authoritative import surface\n\
2794 from newt_agent._newt_agent.core import Router # real path, not a guess";
2795 let mut msgs = vec![sys(kb), user("task")];
2796 for i in 0..24 {
2797 msgs.push(user(&format!("middle note {i} {}", "m".repeat(200))));
2798 }
2799 msgs.push(user("recent tail"));
2800 let mut state = CompressState::new();
2801 let out = run(&msgs, 300, None, None, &mut state).await;
2802 assert!(out.fired, "a large conversation should compress");
2803 assert!(
2804 out.messages.iter().any(|m| m["role"] == "system"
2805 && m["content"]
2806 .as_str()
2807 .is_some_and(|c| c.contains("from newt_agent._newt_agent.core import Router"))),
2808 "the knowledge_base import surface must survive compression VERBATIM \
2809 (the protected head — the stable base E relies on)"
2810 );
2811 }
2812
2813 #[tokio::test]
2815 async fn effective_compressions_do_not_disable() {
2816 let mut state = CompressState::new();
2817 for _ in 0..4 {
2818 let msgs = tool_heavy("task", 6, 4_000);
2819 let before = estimate_tokens(&msgs, EST);
2820 let out = run(&msgs, before / 3, None, None, &mut state).await;
2821 assert_ne!(out.action, CompressAction::Refused);
2822 assert!(out.notice.is_none());
2823 }
2824 assert!(!state.disabled);
2825 }
2826
2827 #[test]
2829 fn thrash_window_requires_consecutive_poor_savings() {
2830 let mut state = CompressState::new();
2831 state.record(1_000, 990, 500); state.record(1_000, 400, 500); state.record(1_000, 990, 500); assert!(!state.disabled, "non-consecutive poor passes never disable");
2835 state.record(1_000, 950, 500); assert!(state.disabled);
2837 }
2838
2839 #[test]
2840 fn budget_aware_gap_progress_is_not_a_strike() {
2841 let mut state = CompressState::new();
2845 state.record(1_000, 920, 800);
2847 state.record(1_000, 920, 800);
2848 assert!(
2849 !state.is_disabled(),
2850 "gap-shrinking passes must not latch the disable"
2851 );
2852 let mut dead = CompressState::new();
2855 dead.record(1_000, 995, 500);
2856 dead.record(1_000, 996, 500);
2857 assert!(dead.is_disabled(), "truly ineffective passes still latch");
2858 }
2859
2860 fn chat_history(turns: usize, chars: usize) -> Vec<Value> {
2865 let mut msgs = vec![sys("you are newt"), user("ORIGINAL TASK: port the parser")];
2866 for i in 0..turns {
2867 msgs.push(user(&format!("q{i} {}", "u".repeat(chars))));
2868 msgs.push(json!({"role": "assistant",
2869 "content": format!("a{i} {}", "v".repeat(chars))}));
2870 }
2871 msgs
2872 }
2873
2874 #[tokio::test]
2878 async fn user_initiated_compresses_without_token_pressure() {
2879 let msgs = chat_history(10, 400);
2880 let prompts = Arc::new(Mutex::new(Vec::new()));
2881 let s = recording_summarizer(prompts.clone(), "## Active Task\nMANUAL SUMMARY");
2882 let mut state = CompressState::new();
2883 let out = compress_user_initiated(&msgs, None, Some(&*s), &mut state, EST, 8_192).await;
2884
2885 assert!(out.fired);
2886 assert_eq!(out.how, CompressAction::Summarized.describe());
2887 assert_eq!(out.messages_before, msgs.len());
2888 assert_eq!(out.messages_after, out.messages.len());
2889 assert!(
2890 out.messages_after < out.messages_before,
2891 "count must shrink"
2892 );
2893 assert!(out.tokens_after < out.tokens_before);
2894 assert!(
2895 out.messages.iter().any(|m| is_compaction_message(m)
2896 && m["content"].as_str().unwrap().contains("MANUAL SUMMARY")),
2897 "marked summary message must be present"
2898 );
2899 let p = prompts.lock().unwrap();
2901 assert!(p[0].contains("ORIGINAL TASK: port the parser"), "{}", p[0]);
2902 let c = state.counters();
2904 assert_eq!(c.compressions, 1);
2905 assert_eq!(c.strikes, 0, "a good reclaim is not a strike");
2906 assert!(c.last_reclaim.unwrap() > THRASH_MIN_SAVINGS);
2907 assert!(!c.disabled);
2908 }
2909
2910 #[tokio::test]
2914 async fn user_initiated_focus_is_threaded_and_redacted() {
2915 let msgs = chat_history(10, 400);
2916 let prompts = Arc::new(Mutex::new(Vec::new()));
2917 let s = recording_summarizer(prompts.clone(), "SUMMARY");
2918 let mut state = CompressState::new();
2919 let secret = "sk-aaaaaaaaaaaaaaaaaaaaaaaa1234";
2920 let focus = format!("the auth flow around {secret} handling");
2921 let out =
2922 compress_user_initiated(&msgs, Some(&focus), Some(&*s), &mut state, EST, 8_192).await;
2923 assert!(out.fired);
2924
2925 let p = prompts.lock().unwrap();
2926 assert_eq!(p.len(), 1);
2927 assert!(
2928 p[0].contains("emphasize anything about"),
2929 "focus guidance line missing: {}",
2930 p[0]
2931 );
2932 assert!(p[0].contains("the auth flow around"), "{}", p[0]);
2933 assert!(
2934 !p[0].contains(secret),
2935 "a secret typed into the focus must never reach the summarizer"
2936 );
2937 assert!(p[0].contains("[REDACTED]"));
2938 }
2939
2940 #[tokio::test]
2943 async fn no_focus_means_no_guidance_line() {
2944 let msgs = chat_history(10, 400);
2945 let prompts = Arc::new(Mutex::new(Vec::new()));
2946 let s = recording_summarizer(prompts.clone(), "SUMMARY");
2947 let mut state = CompressState::new();
2948 compress_user_initiated(&msgs, None, Some(&*s), &mut state, EST, 8_192).await;
2949 assert!(!prompts.lock().unwrap()[0].contains("emphasize anything about"));
2950 }
2951
2952 #[tokio::test]
2956 async fn user_initiated_noop_records_nothing() {
2957 let msgs = vec![sys("you are newt"), user("task"), user("note")];
2958 let mut state = CompressState::new();
2959 for _ in 0..3 {
2960 let out = compress_user_initiated(&msgs, None, None, &mut state, EST, 8_192).await;
2961 assert!(!out.fired, "nothing to reclaim — must not fire");
2962 assert_eq!(out.messages, msgs);
2963 assert_eq!(out.tokens_before, out.tokens_after);
2964 assert!(out.notice.is_none());
2965 }
2966 let c = state.counters();
2967 assert_eq!(c.compressions, 0, "no-op runs never count");
2968 assert_eq!(c.strikes, 0);
2969 assert!(!c.disabled);
2970 assert_eq!(c.last_reclaim, None);
2971 }
2972
2973 #[tokio::test]
2977 async fn user_initiated_runs_while_latched() {
2978 let msgs = chat_history(10, 400);
2979 let mut state = CompressState::new();
2980 state.latch_disabled_for_tests();
2981 let out = compress_user_initiated(&msgs, None, None, &mut state, EST, 8_192).await;
2982 assert!(out.fired, "an explicit ask must bypass the latch");
2983 assert_eq!(out.how, CompressAction::StaticFallback.describe());
2984 assert!(state.is_disabled(), "the latch itself stays set");
2985 }
2986
2987 #[test]
2989 fn counters_snapshot_projects_state() {
2990 let mut state = CompressState::new();
2991 let c = state.counters();
2992 assert_eq!((c.compressions, c.strikes, c.disabled), (0, 0, false));
2993 assert_eq!(c.last_reclaim, None);
2994
2995 state.record(1_000, 400, 500); let c = state.counters();
2997 assert_eq!((c.compressions, c.strikes, c.disabled), (1, 0, false));
2998 assert!((c.last_reclaim.unwrap() - 0.6).abs() < 0.01);
2999
3000 state.record(1_000, 990, 500); let c = state.counters();
3002 assert_eq!((c.compressions, c.strikes, c.disabled), (2, 1, false));
3003
3004 state.record(1_000, 950, 500); let c = state.counters();
3006 assert_eq!((c.compressions, c.strikes, c.disabled), (3, 2, true));
3007 assert!(c.last_reclaim.unwrap() < THRASH_MIN_SAVINGS);
3008 }
3009
3010 #[test]
3013 fn counters_first_poor_attempt_is_one_strike() {
3014 let mut state = CompressState::new();
3015 state.record(1_000, 990, 500);
3016 assert_eq!(state.counters().strikes, 1);
3017 }
3018
3019 #[test]
3022 fn trigger_fires_on_count_token_or_guard() {
3023 assert!(compression_trigger(10, 1_000, 900, 40, None, None, 100).is_none());
3025 assert_eq!(
3027 compression_trigger(4, 60_000, 59_000, 40, Some(50_000), None, 100),
3028 Some(CompressTrigger {
3029 budget: 50_000,
3030 max_messages: None,
3031 hard_budget: true,
3032 })
3033 );
3034 assert_eq!(
3036 compression_trigger(4, 9_000, 8_600, 40, None, Some(8_000), 500),
3037 Some(CompressTrigger {
3038 budget: 7_500,
3039 max_messages: None,
3040 hard_budget: true,
3041 })
3042 );
3043 assert_eq!(
3047 compression_trigger(41, 1_000, 800, 40, None, None, 100),
3048 Some(CompressTrigger {
3049 budget: 400,
3050 max_messages: Some(20),
3051 hard_budget: false,
3052 })
3053 );
3054 assert_eq!(
3056 compression_trigger(41, 60_000, 59_000, 40, Some(50_000), Some(20_000), 500),
3057 Some(CompressTrigger {
3058 budget: 19_500,
3059 max_messages: Some(20),
3060 hard_budget: true,
3061 })
3062 );
3063 assert!(compression_trigger(4, 7_999, 7_000, 40, Some(50_000), Some(8_000), 0).is_none());
3065 }
3066
3067 #[test]
3071 fn trigger_zero_token_budget_is_disabled() {
3072 assert!(compression_trigger(4, 100, 90, 40, Some(0), None, 0).is_none());
3073 assert!(compression_trigger(4, 100, 90, 40, None, Some(0), 10).is_none());
3074 assert_eq!(
3076 compression_trigger(41, 100, 90, 40, Some(0), Some(0), 10),
3077 Some(CompressTrigger {
3078 budget: 45,
3079 max_messages: Some(20),
3080 hard_budget: false,
3081 })
3082 );
3083 }
3084
3085 #[test]
3088 fn redaction_catches_true_positives() {
3089 let cases = [
3090 (
3091 "the key is sk-AbCdEf1234567890AbCdEf1234567890",
3092 "sk-AbCdEf",
3093 ),
3094 ("ghp_AbCdEf1234567890AbCdEf1234567890", "ghp_"),
3095 ("github_pat_11ABCDEFG0123456789_abcdefghij", "github_pat_"),
3096 ("aws id AKIAIOSFODNN7EXAMPLE", "AKIAIOSFODNN7"),
3097 (
3098 "Authorization: Bearer abc.def-ghi_jkl012345678901234567890",
3099 "abc.def-ghi",
3100 ),
3101 ("api_key=9f8e7d6c5b4a32100ffee", "9f8e7d6c"),
3102 ("password: \"hunter2hunter2\"", "hunter2hunter2"),
3103 (
3104 "jwt eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abc123def456",
3105 "eyJhbGci",
3106 ),
3107 ];
3108 for (input, leaked) in cases {
3109 let out = redact_secrets(input);
3110 assert!(
3111 !out.contains(leaked),
3112 "secret fragment {leaked:?} survived: {out}"
3113 );
3114 assert!(out.contains("[REDACTED]"), "no redaction marker: {out}");
3115 }
3116 let key = "-----BEGIN RSA PRIVATE KEY-----\nMIIEow…\n-----END RSA PRIVATE KEY-----";
3118 assert!(!redact_secrets(key).contains("MIIEow"));
3119 let cut = "-----BEGIN PRIVATE KEY-----\nMIIEow… (truncated)";
3120 assert!(!redact_secrets(cut).contains("MIIEow"));
3121 }
3122
3123 #[test]
3124 fn redaction_passes_benign_near_misses() {
3125 let benign = [
3126 "the api key is stored in the system keychain",
3127 "the token budget is 4096 tokens per request",
3128 "Bearer of good news: the build is green",
3129 "sk-test was rejected (too short to be a real key)",
3130 "set password: yes in sshd_config",
3131 "AKIAFOO is not a full key id",
3132 "ghp_short",
3133 "the access_token field is documented in docs/api.md",
3134 "run `cargo test -p newt-core` and check the password prompt",
3135 ];
3136 for input in benign {
3137 let out = redact_secrets(input);
3138 assert_eq!(out, input, "benign text must pass unchanged");
3139 }
3140 }
3141
3142 #[test]
3143 fn redaction_applies_inside_the_summary_request() {
3144 let middle = vec![tool_result(
3145 "config: api_key=9f8e7d6c5b4a32100ffee and more text",
3146 )];
3147 let request = redact_secrets(&summary_request(
3148 "the task",
3149 &middle,
3150 usize::MAX,
3151 None,
3152 ConvShape::Coding,
3153 ));
3154 assert!(!request.contains("9f8e7d6c5b4a32100ffee"), "{request}");
3155 assert!(request.contains("api_key=[REDACTED]"), "{request}");
3156 assert!(request.contains("the task"), "task still present verbatim");
3157 }
3158
3159 #[test]
3160 fn middle_shape_detects_coding_vs_general() {
3161 let coding = vec![serde_json::json!({
3163 "role": "assistant",
3164 "tool_calls": [{"function": {"name": "edit_file", "arguments": "{}"}}],
3165 })];
3166 assert_eq!(middle_shape(&coding), ConvShape::Coding);
3167 let general = vec![
3168 serde_json::json!({"role": "user", "content": "what is a monad?"}),
3169 serde_json::json!({"role": "assistant", "content": "a monoid in ..."}),
3170 ];
3171 assert_eq!(middle_shape(&general), ConvShape::General);
3172 }
3173
3174 #[test]
3175 fn general_shape_swaps_the_section_template() {
3176 let coding = summary_prompt_for("t", "body", None, None, 600, ConvShape::Coding);
3179 assert!(coding.contains("## Completed Actions") && coding.contains("## Relevant Files"));
3180 let general = summary_prompt_for("t", "body", None, None, 600, ConvShape::General);
3181 assert!(general.contains("## Discussion") && general.contains("## Open Questions"));
3182 assert!(
3183 !general.contains("## Relevant Files"),
3184 "no file-centric slot for a Q&A middle"
3185 );
3186 assert!(general.contains("## Active Task") && general.contains("## Critical Context"));
3187 assert!(general.starts_with("You are compressing the middle of a conversation."));
3188 }
3189
3190 #[test]
3193 fn redaction_catches_json_quoted_credential_keys() {
3194 let cases = [
3195 (r#"{"api_key": "9f8e7d6c5b4a32100ffee"}"#, "9f8e7d6c"),
3196 (r#"{"password": "hunter2hunter2"}"#, "hunter2hunter2"),
3197 (
3198 r#"body: "client_secret": "abcd1234efgh5678ijkl""#,
3199 "abcd1234",
3200 ),
3201 ];
3202 for (input, leaked) in cases {
3203 let out = redact_secrets(input);
3204 assert!(
3205 !out.contains(leaked),
3206 "secret fragment {leaked:?} survived: {out}"
3207 );
3208 assert!(out.contains("[REDACTED]"), "no redaction marker: {out}");
3209 }
3210 }
3211
3212 #[test]
3216 fn redaction_survives_excerpt_truncation() {
3217 let secret = "sk-AbCdEf1234567890AbCdEf1234567890";
3218 let args = json!({
3221 "command": format!("{} && export OPENAI_API_KEY={secret}", "x".repeat(140))
3222 });
3223 let m = assistant_call("run_command", args);
3224 let line = render_message(&m);
3225 assert!(!line.contains("sk-AbC"), "{line}");
3226 assert!(!line.contains("AbCdEf123"), "no fragment may leak: {line}");
3227 assert!(line.contains("[REDACTED]"), "{line}");
3228 }
3229
3230 #[test]
3235 fn summary_request_caps_total_middle_size() {
3236 let middle: Vec<Value> = (0..50)
3237 .map(|i| tool_result(&format!("MSG{i} {}", "m".repeat(1_900))))
3238 .collect();
3239 let capped = summary_request("the task", &middle, 8_192, None, ConvShape::Coding);
3240 assert!(
3241 capped.chars().count() < 12_000,
3242 "total must be capped, got {}",
3243 capped.chars().count()
3244 );
3245 assert!(capped.contains("older message(s) omitted"), "{capped:.200}");
3246 assert!(capped.contains("MSG49 "), "most recent middle kept");
3247 assert!(!capped.contains("MSG0 "), "oldest middle dropped");
3248 assert!(capped.contains("the task"), "task always present");
3249
3250 let uncapped = summary_request("the task", &middle, usize::MAX, None, ConvShape::Coding);
3252 assert!(uncapped.chars().count() > 90_000);
3253 assert!(!uncapped.contains("older message(s) omitted"));
3254 }
3255
3256 #[test]
3259 fn chunk_strings_groups_consecutive_within_cap() {
3260 let parts: Vec<String> = ["aaa", "bbb", "ccc", "ddddddd"]
3261 .iter()
3262 .map(|s| s.to_string())
3263 .collect();
3264 assert_eq!(
3267 chunk_strings(&parts, 6),
3268 vec![
3269 "aaabbb".to_string(),
3270 "ccc".to_string(),
3271 "ddddddd".to_string()
3272 ]
3273 );
3274 assert_eq!(chunk_strings(&parts, 1_000).len(), 1);
3276 }
3277
3278 #[tokio::test]
3279 async fn summarize_middle_single_request_when_it_fits() {
3280 let prompts = Arc::new(Mutex::new(Vec::new()));
3281 let s = recording_summarizer(prompts.clone(), "SUMMARY");
3282 let middle = vec![user("alpha"), user("beta")];
3283 let out = summarize_middle(&*s, "do the task", &middle, 100_000, None).await;
3284 assert_eq!(out.as_deref(), Some("SUMMARY"));
3285 assert_eq!(prompts.lock().unwrap().len(), 1, "fits → one request");
3286 }
3287
3288 #[tokio::test]
3289 async fn summarize_middle_chunks_and_reduces_when_over_cap() {
3290 let prompts = Arc::new(Mutex::new(Vec::new()));
3291 let s = recording_summarizer(prompts.clone(), "PART");
3292 let big = "x".repeat(1_000);
3295 let middle: Vec<Value> = (0..6).map(|_| user(&big)).collect();
3296 let out = summarize_middle(&*s, "do the task", &middle, 2_500, None).await;
3297 assert_eq!(out.as_deref(), Some("PART"), "result is the reduce output");
3298 let p = prompts.lock().unwrap();
3299 assert!(
3300 p.len() > 1,
3301 "over-cap middle is chunked: {} requests",
3302 p.len()
3303 );
3304 assert!(
3305 p.iter().any(|r| r.contains("[part 1/")),
3306 "chunks carry part labels"
3307 );
3308 assert!(
3309 p.iter().any(|r| r.contains("consolidate")),
3310 "a reduce/consolidation pass ran"
3311 );
3312 assert!(
3315 p.iter().all(|r| r.chars().count() < 2_500 + 2_000),
3316 "each request stays under the cap (+ template)"
3317 );
3318 }
3319
3320 #[tokio::test]
3321 async fn summarize_middle_all_chunks_fail_degrades_to_none() {
3322 let calls = Arc::new(AtomicUsize::new(0));
3323 let s = failing_summarizer(calls.clone());
3324 let big = "x".repeat(1_000);
3325 let middle: Vec<Value> = (0..6).map(|_| user(&big)).collect();
3326 let out = summarize_middle(&*s, "task", &middle, 2_500, None).await;
3327 assert!(out.is_none(), "all chunks failing → None (→ static marker)");
3328 assert!(
3329 calls.load(Ordering::SeqCst) >= 3,
3330 "every chunk was attempted, got {}",
3331 calls.load(Ordering::SeqCst)
3332 );
3333 }
3334
3335 #[test]
3338 fn render_message_includes_calls_and_caps_content() {
3339 let m = assistant_call("read_file", json!({"path": "src/lib.rs"}));
3340 let line = render_message(&m);
3341 assert!(line.starts_with("[assistant] called read_file("), "{line}");
3342 assert!(line.contains("src/lib.rs"), "{line}");
3343
3344 let long = tool_result(&"w".repeat(10_000));
3345 let line = render_message(&long);
3346 assert!(
3347 line.chars().count() < SUMMARY_INPUT_MSG_CAP + 50,
3348 "{}",
3349 line.len()
3350 );
3351 assert!(line.contains('…'));
3352 }
3353}