Skip to main content

aft/bash_background/
output.rs

1use crate::compress::generic::{ceil_char_boundary, floor_char_boundary};
2
3pub const FINAL_OUTPUT_CAP_BYTES: usize = 16 * 1024;
4pub const FINAL_OUTPUT_HEAD_BYTES: usize = 6 * 1024;
5pub const FINAL_OUTPUT_TAIL_BYTES: usize = 10 * 1024;
6
7pub const RUNNING_OUTPUT_PREVIEW_BYTES: usize = 8 * 1024;
8
9// Completion previews ride inside reminder/wake messages and in-turn tool
10// result appends — they are a glance, not the output channel (full output is
11// always recoverable via bash_status / the stdout+stderr file pointers). They
12// are sized by exit status:
13//  - success: a short tail is all the agent needs ("done, here's the gist");
14//    head context is dead weight at ~150 tokens/task.
15//  - failure: keep a small head (the command banner / first error) plus a
16//    meaningful tail (tracebacks and test summaries land at the end).
17// The uniform 4 KiB head+tail this replaced injected ~1K tokens of mostly
18// build noise per completed task into reminders.
19pub const COMPLETION_SUCCESS_PREVIEW_BYTES: usize = 600;
20pub const COMPLETION_SUCCESS_HEAD_BYTES: usize = 0;
21pub const COMPLETION_SUCCESS_TAIL_BYTES: usize = 600;
22pub const COMPLETION_FAILURE_PREVIEW_BYTES: usize = 2304;
23pub const COMPLETION_FAILURE_HEAD_BYTES: usize = 512;
24pub const COMPLETION_FAILURE_TAIL_BYTES: usize = 1792;
25
26pub const RAW_PASSTHROUGH_CAP_BYTES: usize = 50 * 1024;
27pub const RAW_PASSTHROUGH_HEAD_BYTES: usize = 20 * 1024;
28pub const RAW_PASSTHROUGH_TAIL_BYTES: usize = 30 * 1024;
29
30pub const STRUCTURED_OUTPUT_CAP_BYTES: usize = 50 * 1024;
31
32pub const COMPRESS_INPUT_CAP_BYTES: usize = 10 * 1024 * 1024;
33pub const COMPRESS_INPUT_HEAD_BYTES: usize = 4 * 1024 * 1024;
34pub const COMPRESS_INPUT_TAIL_BYTES: usize = 6 * 1024 * 1024;
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct CappedText {
38    pub text: String,
39    pub truncated: bool,
40}
41
42pub fn cap_final_output(input: &str) -> CappedText {
43    cap_head_tail(
44        input,
45        FINAL_OUTPUT_CAP_BYTES,
46        FINAL_OUTPUT_HEAD_BYTES,
47        FINAL_OUTPUT_TAIL_BYTES,
48    )
49}
50
51pub fn cap_final_output_with_marker(input: &str, marker: &str) -> CappedText {
52    cap_head_tail_with_marker(
53        input,
54        FINAL_OUTPUT_CAP_BYTES,
55        FINAL_OUTPUT_HEAD_BYTES,
56        FINAL_OUTPUT_TAIL_BYTES,
57        marker,
58    )
59}
60
61/// Byte threshold under which a completion preview is passed through uncapped.
62pub fn completion_preview_threshold(exit_ok: bool) -> usize {
63    completion_caps(exit_ok).0
64}
65
66fn completion_caps(exit_ok: bool) -> (usize, usize, usize) {
67    if exit_ok {
68        (
69            COMPLETION_SUCCESS_PREVIEW_BYTES,
70            COMPLETION_SUCCESS_HEAD_BYTES,
71            COMPLETION_SUCCESS_TAIL_BYTES,
72        )
73    } else {
74        (
75            COMPLETION_FAILURE_PREVIEW_BYTES,
76            COMPLETION_FAILURE_HEAD_BYTES,
77            COMPLETION_FAILURE_TAIL_BYTES,
78        )
79    }
80}
81
82/// Cap a completion preview by exit status: success keeps a short tail,
83/// failure keeps a small head plus a larger tail (see the constants above).
84pub fn cap_completion_output(input: &str, exit_ok: bool) -> CappedText {
85    let (threshold, head, tail) = completion_caps(exit_ok);
86    cap_head_tail(input, threshold, head, tail)
87}
88
89pub fn cap_completion_output_with_marker(input: &str, marker: &str, exit_ok: bool) -> CappedText {
90    let (threshold, head, tail) = completion_caps(exit_ok);
91    cap_head_tail_with_marker(input, threshold, head, tail, marker)
92}
93
94pub fn cap_head_tail(
95    input: &str,
96    threshold_bytes: usize,
97    keep_head: usize,
98    keep_tail: usize,
99) -> CappedText {
100    if input.len() <= threshold_bytes {
101        return CappedText {
102            text: input.to_string(),
103            truncated: false,
104        };
105    }
106
107    let head_end = floor_char_boundary(input, keep_head.min(input.len()));
108    let mut tail_start = ceil_char_boundary(input, input.len().saturating_sub(keep_tail));
109
110    if head_end >= tail_start {
111        return CappedText {
112            text: input.to_string(),
113            truncated: false,
114        };
115    }
116
117    let marker_prefix_len = if head_end == 0 || input[..head_end].ends_with('\n') {
118        0
119    } else {
120        1
121    };
122    loop {
123        let truncated_bytes = tail_start - head_end;
124        let marker_len = marker_prefix_len
125            + "...<truncated ".len()
126            + truncated_bytes.to_string().len()
127            + " bytes>...\n".len();
128        let max_tail = threshold_bytes.saturating_sub(head_end + marker_len);
129        let adjusted_tail_start = ceil_char_boundary(input, input.len().saturating_sub(max_tail));
130        if adjusted_tail_start <= tail_start {
131            break;
132        }
133        tail_start = adjusted_tail_start;
134        if head_end >= tail_start {
135            return CappedText {
136                text: input.to_string(),
137                truncated: false,
138            };
139        }
140    }
141
142    let truncated_bytes = tail_start - head_end;
143    let mut output = String::with_capacity(threshold_bytes.min(input.len()));
144    output.push_str(&input[..head_end]);
145    // Only insert a separator newline when there IS a head that doesn't end
146    // with one — mirroring marker_prefix_len above. With keep_head == 0 the
147    // old `!output.ends_with('\n')` check pushed an unbudgeted leading
148    // newline (empty string never ends with '\n'), overshooting the
149    // threshold by one byte.
150    if head_end > 0 && !output.ends_with('\n') {
151        output.push('\n');
152    }
153    output.push_str("...<truncated ");
154    output.push_str(&truncated_bytes.to_string());
155    output.push_str(" bytes>...\n");
156    output.push_str(&input[tail_start..]);
157
158    CappedText {
159        text: output,
160        truncated: true,
161    }
162}
163
164pub fn cap_head_tail_with_marker(
165    input: &str,
166    threshold_bytes: usize,
167    keep_head: usize,
168    keep_tail: usize,
169    marker: &str,
170) -> CappedText {
171    if marker.is_empty() {
172        return cap_head_tail(input, threshold_bytes, keep_head, keep_tail);
173    }
174    if input.len() <= threshold_bytes {
175        let with_marker = append_marker_line(input, marker);
176        if with_marker.len() <= threshold_bytes {
177            return CappedText {
178                text: with_marker,
179                truncated: true,
180            };
181        }
182    }
183
184    let mut head_budget = keep_head.min(input.len());
185    let mut tail_budget = keep_tail.min(input.len());
186    let mut seen_budgets = Vec::new();
187
188    for _ in 0..8 {
189        let head_end = floor_char_boundary(input, head_budget);
190        let marker_prefix_len = if head_end == 0 || input[..head_end].ends_with('\n') {
191            0
192        } else {
193            1
194        };
195        let marker_len = marker_prefix_len + marker.len() + 1;
196        let available = threshold_bytes.saturating_sub(marker_len);
197        let next_head = keep_head.min(available).min(input.len());
198        let next_tail = keep_tail
199            .min(available.saturating_sub(next_head))
200            .min(input.len().saturating_sub(next_head));
201
202        if next_head == head_budget && next_tail == tail_budget {
203            break;
204        }
205        if seen_budgets.contains(&(next_head, next_tail)) {
206            if next_head.saturating_add(next_tail) < head_budget.saturating_add(tail_budget) {
207                head_budget = next_head;
208                tail_budget = next_tail;
209            }
210            break;
211        }
212        seen_budgets.push((head_budget, tail_budget));
213        head_budget = next_head;
214        tail_budget = next_tail;
215    }
216
217    let head_end = floor_char_boundary(input, head_budget);
218    let tail_start = ceil_char_boundary(input, input.len().saturating_sub(tail_budget));
219    let tail_start = if head_end >= tail_start {
220        input.len()
221    } else {
222        tail_start
223    };
224
225    CappedText {
226        text: marker_capped_output(input, head_end, tail_start, marker, threshold_bytes),
227        truncated: true,
228    }
229}
230
231fn append_marker_line(input: &str, marker: &str) -> String {
232    if input.is_empty() {
233        return marker.to_string();
234    }
235    let mut output = input.trim_end().to_string();
236    output.push('\n');
237    output.push_str(marker);
238    output
239}
240
241fn marker_capped_output(
242    input: &str,
243    head_end: usize,
244    tail_start: usize,
245    marker: &str,
246    threshold_bytes: usize,
247) -> String {
248    let output = marker_output(input, head_end, tail_start, marker);
249    if output.len() <= threshold_bytes {
250        return output;
251    }
252
253    let separator_len = usize::from(head_end > 0 && !input[..head_end].ends_with('\n'));
254    let marker_line_len = marker.len().saturating_add(1);
255    let tail_budget = threshold_bytes.saturating_sub(head_end + separator_len + marker_line_len);
256    let adjusted_tail_start = ceil_char_boundary(input, input.len().saturating_sub(tail_budget));
257    let output = marker_output(input, head_end, tail_start.max(adjusted_tail_start), marker);
258    if output.len() <= threshold_bytes {
259        return output;
260    }
261
262    // If the marker itself still fits, prefer preserving it and trimming all head
263    // content before resorting to a marker-only hard cap.
264    let tail_budget = threshold_bytes.saturating_sub(marker_line_len);
265    let adjusted_tail_start = ceil_char_boundary(input, input.len().saturating_sub(tail_budget));
266    let output = marker_output(input, 0, adjusted_tail_start, marker);
267    if output.len() <= threshold_bytes {
268        return output;
269    }
270
271    marker[..floor_char_boundary(marker, threshold_bytes.min(marker.len()))].to_string()
272}
273
274fn marker_output(input: &str, head_end: usize, tail_start: usize, marker: &str) -> String {
275    let mut output = String::new();
276    output.push_str(&input[..head_end]);
277    if head_end > 0 && !output.ends_with('\n') {
278        output.push('\n');
279    }
280    output.push_str(marker);
281    if tail_start < input.len() {
282        output.push('\n');
283        output.push_str(&input[tail_start..]);
284    }
285    output
286}
287
288pub fn quote_path(path: &str) -> String {
289    // The path is shown to the agent as `read "<path>"` for the AFT read tool
290    // (and, for the Unix-only `tail -n +N` form, a shell hint). The read tool
291    // consumes the path literally, so we must NOT backslash-double: on Windows
292    // `C:\Users\foo` would become `C:\\Users\\foo` and the agent would copy a
293    // corrupted path. Only escape an embedded `"` so the surrounding quotes
294    // can't be broken (paths containing `"` are illegal on Windows and rare on
295    // Unix). Realistic Unix paths have no backslashes, so this is a no-op there.
296    let escaped = path.replace('"', "\\\"");
297    format!("\"{escaped}\"")
298}
299
300pub fn json_output_pointer(total_bytes: u64, path: &str) -> String {
301    let kb = total_bytes.div_ceil(1024);
302    format!(
303        "[JSON output {kb} KB; full output: read {}]",
304        quote_path(path)
305    )
306}
307
308pub fn retained_json_output_pointer(
309    total_bytes: u64,
310    path: &str,
311    truncated_prefix_bytes: u64,
312) -> String {
313    let kb = total_bytes.div_ceil(1024);
314    format!(
315        "[JSON output {kb} KB; truncated {truncated_prefix_bytes} bytes from saved output prefix; retained output: read {}]",
316        quote_path(path)
317    )
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn retained_json_pointer_does_not_claim_full_output() {
326        let pointer = retained_json_output_pointer(2048, "/tmp/stdout", 4096);
327
328        assert!(pointer.contains("truncated 4096 bytes from saved output prefix"));
329        assert!(pointer.contains(r#"retained output: read "/tmp/stdout""#));
330        assert!(!pointer.contains("full output"));
331    }
332
333    #[test]
334    fn quote_path_preserves_windows_backslashes() {
335        // The recovery marker shows the agent `read "<path>"` for the AFT read
336        // tool, which consumes the path literally. A Windows path must survive
337        // verbatim (no backslash doubling), otherwise the agent copies a broken
338        // path. Regression for the cross-platform CI failure.
339        assert_eq!(
340            quote_path(r"C:\Users\foo\stdout"),
341            r#""C:\Users\foo\stdout""#
342        );
343        assert_eq!(quote_path(r"C:\a\b"), r#""C:\a\b""#);
344        assert_eq!(quote_path("/tmp/out"), r#""/tmp/out""#);
345        // An embedded double-quote is still escaped so the wrapping can't break.
346        assert_eq!(quote_path(r#"a"b"#), r#""a\"b""#);
347    }
348
349    #[test]
350    fn head_tail_cap_respects_utf8_boundaries() {
351        let input = format!("{}{}", "🦀".repeat(4_000), "tail");
352        let capped = cap_head_tail(&input, 128, 64, 64);
353        assert!(capped.truncated);
354        assert!(capped.text.is_char_boundary(capped.text.len()));
355        assert!(capped.text.contains("...<truncated "));
356        assert!(capped.text.len() <= 128);
357        assert!(capped.text.ends_with("tail"));
358    }
359
360    #[test]
361    fn marker_cap_respects_newline_boundary_oscillation() {
362        let capped = cap_head_tail_with_marker("01234567\nabcdef", 20, 15, 0, "123456789");
363
364        assert!(capped.truncated);
365        assert!(capped.text.len() <= 20, "{}", capped.text.len());
366        assert!(capped.text.is_char_boundary(capped.text.len()));
367        assert!(!capped.text.starts_with('\n'));
368    }
369
370    #[test]
371    fn marker_cap_respects_tiny_threshold_and_long_marker() {
372        let capped = cap_head_tail_with_marker("abcdef", 5, 0, 0, "[very long recovery marker]");
373
374        assert!(capped.truncated);
375        assert!(capped.text.len() <= 5, "{}", capped.text.len());
376        assert!(capped.text.is_char_boundary(capped.text.len()));
377        assert!(!capped.text.starts_with('\n'));
378    }
379
380    #[test]
381    fn marker_cap_accounts_for_marker_when_body_is_under_threshold() {
382        let capped = cap_head_tail_with_marker("0123456789", 10, 10, 0, "[x]");
383
384        assert!(capped.truncated);
385        assert!(capped.text.len() <= 10, "{}", capped.text.len());
386        assert!(capped.text.contains("[x]"));
387    }
388
389    #[test]
390    fn completion_cap_success_is_tail_only_and_small() {
391        // A successful task's reminder preview keeps a short tail — no head.
392        // Regression: the uniform 4 KiB head+tail cap flooded completion
393        // reminders with build noise (~1K tokens per task).
394        let input = format!("HEAD-NOISE\n{}\nFINAL SUMMARY LINE", "x".repeat(8_000));
395        let capped = cap_completion_output(&input, true);
396        assert!(capped.truncated);
397        assert!(
398            capped.text.len() <= COMPLETION_SUCCESS_PREVIEW_BYTES,
399            "{}",
400            capped.text.len()
401        );
402        assert!(capped.text.ends_with("FINAL SUMMARY LINE"));
403        assert!(!capped.text.contains("HEAD-NOISE"));
404    }
405
406    #[test]
407    fn completion_cap_failure_keeps_head_and_larger_tail() {
408        // A failed task keeps a small head (command banner / first error) and
409        // a meaningful tail (tracebacks land at the end).
410        let input = format!(
411            "FIRST-ERROR-CONTEXT\n{}\nTraceback: ModuleNotFoundError",
412            "y".repeat(8_000)
413        );
414        let capped = cap_completion_output(&input, false);
415        assert!(capped.truncated);
416        assert!(
417            capped.text.len() <= COMPLETION_FAILURE_PREVIEW_BYTES,
418            "{}",
419            capped.text.len()
420        );
421        assert!(capped.text.starts_with("FIRST-ERROR-CONTEXT"));
422        assert!(capped.text.ends_with("Traceback: ModuleNotFoundError"));
423        // Failure budget is larger than success budget but far below the old 4 KiB.
424        const {
425            assert!(COMPLETION_FAILURE_PREVIEW_BYTES > COMPLETION_SUCCESS_PREVIEW_BYTES);
426            assert!(COMPLETION_FAILURE_PREVIEW_BYTES < 4 * 1024);
427        }
428    }
429
430    #[test]
431    fn completion_cap_passes_short_output_through_untouched() {
432        let input = "ok: 12 tests passed";
433        for exit_ok in [true, false] {
434            let capped = cap_completion_output(input, exit_ok);
435            assert!(!capped.truncated);
436            assert_eq!(capped.text, input);
437        }
438    }
439}