Skip to main content

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}