atomcode_core/ctx/truncate.rs
1use crate::conversation::message::{Message, MessageContent};
2use crate::tool::ToolResult;
3
4/// Dispatch to per-tool truncation based on tool name, then enforce universal upper bounds.
5///
6/// Per-tool truncation is the first line of defense (bash strips build noise, read_file
7/// extracts outlines, etc.). The universal caps below are the LAST line of defense —
8/// they cap `result.output` regardless of which tool produced it, so a single oversized
9/// `ToolResult` can never dominate the ctx budget:
10///
11/// - `UNIVERSAL_MAX_LINES`: line-count ceiling (head 50 + tail 50 + "[N lines omitted]")
12/// - `hard_char_limit`: char ceiling scaled to ~8K tokens, never more than 1/8 of window
13///
14/// 2026-04-13 context: a 14072-line `find` output contributed to a sent=0 cascade.
15/// Per-tool truncate handled that case (head 10 + tail 20), but other pathological
16/// outputs (unknown tools, huge grep, edit results with diffs) could still slip through
17/// the old `char_limit = max(16000, context_window)` formula which scaled UP with ctx
18/// window and let a single message consume 25% of a 64K budget.
19pub fn truncate_output(result: &mut ToolResult, tool_name: &str, context_window: usize) {
20 match tool_name {
21 // bash: no per-tool truncation. The universal line/char caps below
22 // are sufficient and purely numeric. Pattern-based "smart
23 // extraction" (removed 2026-04-22) assumed English error keywords
24 // (`error`/`FAILED`/`panic`) and hard-coded build tool names
25 // (`cargo build`/`mvn compile`/`vite build`), which silently
26 // dropped non-matching stderr — e.g. a 50-line Chinese compiler
27 // trace was collapsed into `[... N lines skipped ...]` with no
28 // diagnostic content surviving. Technology-stack neutrality is a
29 // project rule (see `project_principles_vs_claude_md.md`), and
30 // main's `turn/runner.rs::detect_call_loop` now catches the
31 // retry-loop bug class that smart-extraction was trying to
32 // prevent.
33 "bash" => {}
34 "read_file" => {} // Layer A in read.rs is the single authority. No post-hoc truncation.
35 "web_fetch" => truncate_generic(result, 150, 20, 40),
36 _ => truncate_generic(result, 200, 30, 50),
37 }
38
39 // ── Universal line-count ceiling ──
40 // Applies after per-tool truncate. Protects against: unknown tools with no
41 // per-tool logic, compile error compression that fails to shrink, edge-case
42 // formats with embedded huge blobs.
43 //
44 // SKIP for read_file: it has its own 2000-line intelligent truncation
45 // (truncate_read_file) that extracts outlines. The 300-line blanket cap
46 // is too aggressive for typical source files (Vue SFC 300-500 lines,
47 // Java 200-400 lines) — it cuts navItems/data definitions in the middle,
48 // causing edit_file old_string mismatch on the next turn.
49 // The hard_char_limit (Layer 3 below) still applies as the safety net.
50 if tool_name != "read_file" {
51 const UNIVERSAL_MAX_LINES: usize = 300;
52 let line_count = result.output.lines().count();
53 if line_count > UNIVERSAL_MAX_LINES {
54 let lines: Vec<&str> = result.output.lines().collect();
55 const HEAD: usize = 50;
56 const TAIL: usize = 50;
57 let head_part = lines[..HEAD].join("\n");
58 let tail_part = lines[lines.len() - TAIL..].join("\n");
59 result.output = format!(
60 "{}\n\n[... {} lines omitted (universal 300-line cap) ...]\n\n{}",
61 head_part,
62 line_count - HEAD - TAIL,
63 tail_part,
64 );
65 }
66 }
67
68 // ── Universal char-count ceiling ──
69 // ── INVARIANT (2026-04-16): read_file MUST be skipped here ──
70 // read_file has its own truncation (auto_skeleton + dynamic char_limit
71 // in read.rs). This universal cap was the root cause of 26-turn
72 // exploration sessions: 950-line file (38K chars) truncated to 8K
73 // (200 lines), forcing 20+ turns of grep/read fragments.
74 // Fixed in 4fc5cda, accidentally reverted by 4f704cb (whole-file
75 // revert to restore verify.rs hit this as collateral damage).
76 // Other tools (bash, grep, etc.) still get the char cap.
77 // ────────────────────────────────────────────────────────────
78 let hard_char_limit = (context_window / 8).min(32_000).max(8_000);
79 if tool_name == "read_file" {
80 // read_file: no char cap. Managed by read.rs internally:
81 // 1. auto_skeleton (file_tokens > budget/5)
82 // 2. dynamic char_limit (budget-scaled, not hardcoded)
83 // 3. truncate_read_file above (>2000 lines → outline)
84 } else if result.output.len() > hard_char_limit {
85 // Preserve head AND tail when cutting — tools often put errors/status at the end.
86 let chars: Vec<char> = result.output.chars().collect();
87 let head_chars = hard_char_limit * 2 / 3;
88 let tail_chars = hard_char_limit / 3;
89 let head_part: String = chars[..head_chars.min(chars.len())].iter().collect();
90 let tail_part: String = chars[chars.len().saturating_sub(tail_chars)..]
91 .iter()
92 .collect();
93 let omitted = chars.len().saturating_sub(head_chars + tail_chars);
94 result.output = format!(
95 "{}\n\n[... {} chars omitted (universal {} char cap) ...]\n\n{}",
96 head_part, omitted, hard_char_limit, tail_part,
97 );
98 }
99}
100
101// truncate_bash + try_compress_compile_errors + assemble_important_lines
102// were removed 2026-04-22 (~250 lines) to enforce technology-stack
103// neutrality. See comment at top of `truncate_output` for why.
104
105// truncate_read_file: DELETED.
106// read_file truncation is now handled exclusively by Layer A (auto_skeleton)
107// in read.rs. Having two separate outline-extraction algorithms (tree-sitter
108// in read.rs vs indent-based here) was redundant and caused confusion about
109// which one actually controlled the output.
110
111/// Generic truncation: head + tail, skipping middle.
112pub(crate) fn truncate_generic(
113 result: &mut ToolResult,
114 max_lines: usize,
115 head: usize,
116 tail: usize,
117) {
118 let lines: Vec<&str> = result.output.lines().collect();
119 if lines.len() > max_lines {
120 let head_part: String = lines[..head].join("\n");
121 let tail_part: String = lines[lines.len() - tail..].join("\n");
122 result.output = format!(
123 "{}\n\n[... {} lines omitted ...]\n\n{}",
124 head_part,
125 lines.len() - head - tail,
126 tail_part
127 );
128 }
129}
130
131/// Apply truncation to all tool result messages
132/// in the last `tool_count` messages of the conversation.
133///
134/// Two-pass: first per-result truncation, then per-turn budget enforcement.
135/// Per-turn budget = 1/4 of context window (max 16K chars). If all results
136/// in this turn exceed that, aggressively shrink the largest results.
137pub fn post_process_tool_results(
138 messages: &mut Vec<Message>,
139 tool_count: usize,
140 current_tool_name: &str,
141 context_window: usize,
142) {
143 let len = messages.len();
144 let start = len.saturating_sub(tool_count);
145
146 // Build call_id → real tool_name lookup so each ToolResult is
147 // truncated by the rules of the tool that actually produced it.
148 // Without this a mixed-tool turn (e.g. read_file → bash) would
149 // truncate every result under whichever tool ran last
150 // (`current_tool_name`), which inverts read_file's cap exemption
151 // and shrinks file contents to ~30 lines.
152 let mut call_id_to_tool: std::collections::HashMap<String, String> =
153 std::collections::HashMap::new();
154 for msg in messages.iter() {
155 if let MessageContent::AssistantWithToolCalls { tool_calls, .. } = &msg.content {
156 for tc in tool_calls {
157 call_id_to_tool.insert(tc.id.clone(), tc.name.clone());
158 }
159 }
160 }
161
162 // Pass 1: per-result truncation, keyed by each result's real tool.
163 // `current_tool_name` is the fallback for results with no paired
164 // ATC in the message vec (e.g. orphaned test fixtures).
165 for i in start..len {
166 if let MessageContent::ToolResult(ref r) = messages[i].content {
167 let tool_name = call_id_to_tool
168 .get(&r.call_id)
169 .map(|s| s.as_str())
170 .unwrap_or(current_tool_name);
171 let mut result = r.clone();
172 truncate_output(&mut result, tool_name, context_window);
173 messages[i].content = MessageContent::ToolResult(result);
174 }
175 }
176
177 // Pass 2: per-turn budget enforcement.
178 // INVARIANT (2026-04-16): turn_budget must scale with context_window.
179 // Was capped at 16K chars, which at 128K ctx meant a single turn of
180 // 3 file reads got "trimmed to fit turn budget" — the model saw
181 // different fragments each re-read and couldn't correlate them.
182 // Now: ctx/4 with cap at 64K chars, floor 4K.
183 let turn_budget = (context_window / 4).min(64_000).max(4_000);
184 let mut total_chars: usize = 0;
185 for i in start..len {
186 if let MessageContent::ToolResult(ref r) = messages[i].content {
187 total_chars += r.output.len();
188 }
189 }
190
191 if total_chars > turn_budget {
192 let ratio = turn_budget as f64 / total_chars as f64;
193 for i in start..len {
194 if let MessageContent::ToolResult(ref r) = messages[i].content {
195 let target = (r.output.len() as f64 * ratio) as usize;
196 if r.output.len() > target && target > 200 {
197 let mut result = r.clone();
198 let chars: Vec<char> = result.output.chars().collect();
199 let head = target * 2 / 3;
200 let tail = target / 3;
201 let head_part: String = chars[..head.min(chars.len())].iter().collect();
202 let tail_part: String =
203 chars[chars.len().saturating_sub(tail)..].iter().collect();
204 result.output = format!(
205 "{}\n[... trimmed to fit turn budget ...]\n{}",
206 head_part, tail_part,
207 );
208 messages[i].content = MessageContent::ToolResult(result);
209 }
210 }
211 }
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use crate::conversation::message::{Message, MessageContent, Role};
219 use crate::tool::{ToolCall, ToolResult};
220
221 fn make_result(output: &str) -> ToolResult {
222 ToolResult {
223 call_id: "test_call".to_string(),
224 output: output.to_string(),
225 success: true,
226 }
227 }
228
229 fn make_tool_result_message(output: &str) -> Message {
230 Message {
231 role: Role::Tool,
232 content: MessageContent::ToolResult(make_result(output)),
233 }
234 }
235
236 fn make_atc(call_id: &str, tool_name: &str) -> Message {
237 Message {
238 role: Role::Assistant,
239 content: MessageContent::AssistantWithToolCalls {
240 text: None,
241 tool_calls: vec![ToolCall {
242 id: call_id.to_string(),
243 name: tool_name.to_string(),
244 arguments: String::new(),
245 }],
246 reasoning_content: None,
247 thinking_blocks: Vec::new(),
248 },
249 }
250 }
251
252 fn make_tool_result_with_id(call_id: &str, output: &str) -> Message {
253 Message {
254 role: Role::Tool,
255 content: MessageContent::ToolResult(ToolResult {
256 call_id: call_id.to_string(),
257 output: output.to_string(),
258 success: true,
259 }),
260 }
261 }
262
263 // --- bash truncation tests (A1, 2026-04-22) ---
264 //
265 // bash has no per-tool truncation — relies entirely on the universal
266 // line/char caps in `truncate_output`. These tests lock in that
267 // behavior so future refactors don't silently reintroduce pattern-based
268 // extraction.
269
270 #[test]
271 fn bash_short_output_passes_through_verbatim() {
272 let output: String = (0..100)
273 .map(|i| format!("line {}", i))
274 .collect::<Vec<_>>()
275 .join("\n");
276 let mut result = make_result(&output);
277 truncate_output(&mut result, "bash", 64_000);
278 assert_eq!(
279 result.output, output,
280 "bash output under 300 lines must not be touched"
281 );
282 }
283
284 #[test]
285 fn bash_huge_output_hits_universal_line_cap_only() {
286 // 500 lines > UNIVERSAL_MAX_LINES (300) → head 50 + tail 50 + marker.
287 // Purely numeric — no English error-keyword heuristic fires.
288 let output: String = (0..500)
289 .map(|i| format!("line {}", i))
290 .collect::<Vec<_>>()
291 .join("\n");
292 let mut result = make_result(&output);
293 truncate_output(&mut result, "bash", 64_000);
294 assert!(result.output.contains("line 0"), "head must be preserved");
295 assert!(result.output.contains("line 499"), "tail must be preserved");
296 assert!(
297 result.output.contains("lines omitted"),
298 "omission marker required"
299 );
300 assert!(result.output.lines().count() <= 110);
301 }
302
303 #[test]
304 fn bash_chinese_stderr_survives_truncation() {
305 // Regression test for the 2026-04-22 forensic finding: the old
306 // pattern-based `truncate_bash` collapsed any line not matching
307 // English `error`/`Error`/`FAILED`/`panic` into
308 // `[... N lines skipped ...]`. A 50-line Chinese compiler trace
309 // was reduced to head+tail-only with every middle line dropped.
310 // Under A1 the output passes through verbatim (below universal
311 // caps).
312 let output: String = (0..50)
313 .map(|_| "编译失败:找不到符号".to_string())
314 .collect::<Vec<_>>()
315 .join("\n");
316 let mut result = make_result(&output);
317 truncate_output(&mut result, "bash", 64_000);
318 assert_eq!(result.output.matches("编译失败").count(), 50);
319 }
320
321 // truncate_read_file tests: DELETED (function removed, Layer A in read.rs handles it)
322
323 // --- truncate_generic tests ---
324
325 #[test]
326 fn truncate_generic_under_limit_unchanged() {
327 let output = "line1\nline2\nline3\n";
328 let mut result = make_result(output);
329 truncate_generic(&mut result, 200, 30, 50);
330 assert_eq!(result.output, output);
331 }
332
333 #[test]
334 fn truncate_generic_over_limit_has_head_and_tail() {
335 let lines: Vec<String> = (0..300).map(|i| format!("line {}", i)).collect();
336 let output = lines.join("\n");
337 let mut result = make_result(&output);
338 truncate_generic(&mut result, 200, 30, 50);
339 // Should be shorter
340 assert!(result.output.len() < output.len());
341 // Should contain head (line 0) and tail (line 299)
342 assert!(result.output.contains("line 0"));
343 assert!(result.output.contains("line 299"));
344 // Should contain omit marker
345 assert!(result.output.contains("lines omitted"));
346 }
347
348 // --- truncate_output universal cap tests ---
349
350 #[test]
351 fn truncate_output_hard_char_limit() {
352 // With ctx_window=16000, new formula gives hard_char_limit = max(16000/8, 8000) = 8000.
353 let output = "x".repeat(20000);
354 let mut result = make_result(&output);
355 truncate_output(&mut result, "unknown_tool", 16000);
356 // Result should be at most ~8000 chars + omission marker.
357 assert!(
358 result.output.len() <= 8_500,
359 "got {} chars",
360 result.output.len()
361 );
362 assert!(
363 result.output.contains("chars omitted"),
364 "got: {}",
365 result.output
366 );
367 }
368
369 #[test]
370 fn truncate_output_universal_line_cap() {
371 // 500-line output should get capped to ~100 lines (50 head + 50 tail) + markers.
372 let output: String = (0..500)
373 .map(|i| format!("line {}", i))
374 .collect::<Vec<_>>()
375 .join("\n");
376 let mut result = make_result(&output);
377 truncate_output(&mut result, "unknown_tool", 64_000);
378 let line_count = result.output.lines().count();
379 assert!(
380 line_count <= 110,
381 "got {} lines, expected ≤ 110",
382 line_count
383 );
384 assert!(result.output.contains("lines omitted"));
385 }
386
387 #[test]
388 fn truncate_output_caps_never_grow_with_huge_window() {
389 // Even with a 1M ctx window, a single tool_result must stay ≤ 32K chars.
390 let output = "x".repeat(200_000);
391 let mut result = make_result(&output);
392 truncate_output(&mut result, "unknown_tool", 1_000_000);
393 assert!(
394 result.output.len() <= 33_000,
395 "single tool output should never exceed 32K chars, got {}",
396 result.output.len()
397 );
398 }
399
400 // --- post_process_tool_results tests ---
401
402 #[test]
403 fn post_process_truncates_results() {
404 let large_output = "x".repeat(20000);
405 let mut messages = vec![make_tool_result_message(&large_output)];
406 post_process_tool_results(&mut messages, 1, "unknown_tool", 16000);
407 // Should be truncated but remain inline ToolResult
408 assert!(matches!(messages[0].content, MessageContent::ToolResult(_)));
409 if let MessageContent::ToolResult(ref r) = messages[0].content {
410 // 8K cap + omission marker ≈ 8500 chars worst case.
411 assert!(r.output.len() <= 8_500);
412 }
413 }
414
415 #[test]
416 fn post_process_keeps_small_results_unchanged() {
417 let small_output = "short output";
418 let mut messages = vec![make_tool_result_message(small_output)];
419 post_process_tool_results(&mut messages, 1, "bash", 16000);
420 assert!(matches!(messages[0].content, MessageContent::ToolResult(_)));
421 if let MessageContent::ToolResult(ref r) = messages[0].content {
422 assert_eq!(r.output, "short output");
423 }
424 }
425
426 /// Regression: in a mixed-tool turn, each ToolResult must be truncated
427 /// using the rules of the tool that actually produced it — looked up
428 /// via call_id → ATC.name — NOT `current_tool_name` (which only
429 /// reflects whichever tool ran last). Without this, a `read_file`
430 /// result in a `read_file → bash` turn loses its hard-char-limit
431 /// exemption and gets shrunk to bash's HEAD+TAIL, defeating the
432 /// file-content preservation invariant.
433 #[test]
434 fn post_process_keys_truncation_by_each_result_tool_not_current() {
435 // 400-line "file content" — would trip bash's HEAD 10 + TAIL 20
436 // and the universal 300-line cap if keyed as bash, but read_file
437 // is explicitly exempt from both.
438 let file_content: String = (0..400)
439 .map(|i| format!("line {}", i))
440 .collect::<Vec<_>>()
441 .join("\n");
442 let original_line_count = file_content.lines().count();
443
444 let mut messages = vec![
445 make_atc("rf1", "read_file"),
446 make_tool_result_with_id("rf1", &file_content),
447 ];
448
449 // current_tool_name="bash" as if bash ran last in this turn.
450 // The read_file result must still be recognized as read_file.
451 post_process_tool_results(&mut messages, 2, "bash", 128_000);
452
453 if let MessageContent::ToolResult(ref r) = messages[1].content {
454 assert_eq!(
455 r.output.lines().count(),
456 original_line_count,
457 "read_file content must stay intact when current_tool_name \
458 is a different tool — got {} lines (expected {})",
459 r.output.lines().count(),
460 original_line_count,
461 );
462 } else {
463 panic!("expected ToolResult at index 1");
464 }
465 }
466}