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
308#[cfg(test)]
309mod tests {
310    use super::*;
311
312    #[test]
313    fn quote_path_preserves_windows_backslashes() {
314        // The recovery marker shows the agent `read "<path>"` for the AFT read
315        // tool, which consumes the path literally. A Windows path must survive
316        // verbatim (no backslash doubling), otherwise the agent copies a broken
317        // path. Regression for the cross-platform CI failure.
318        assert_eq!(
319            quote_path(r"C:\Users\foo\stdout"),
320            r#""C:\Users\foo\stdout""#
321        );
322        assert_eq!(quote_path(r"C:\a\b"), r#""C:\a\b""#);
323        assert_eq!(quote_path("/tmp/out"), r#""/tmp/out""#);
324        // An embedded double-quote is still escaped so the wrapping can't break.
325        assert_eq!(quote_path(r#"a"b"#), r#""a\"b""#);
326    }
327
328    #[test]
329    fn head_tail_cap_respects_utf8_boundaries() {
330        let input = format!("{}{}", "🦀".repeat(4_000), "tail");
331        let capped = cap_head_tail(&input, 128, 64, 64);
332        assert!(capped.truncated);
333        assert!(capped.text.is_char_boundary(capped.text.len()));
334        assert!(capped.text.contains("...<truncated "));
335        assert!(capped.text.len() <= 128);
336        assert!(capped.text.ends_with("tail"));
337    }
338
339    #[test]
340    fn marker_cap_respects_newline_boundary_oscillation() {
341        let capped = cap_head_tail_with_marker("01234567\nabcdef", 20, 15, 0, "123456789");
342
343        assert!(capped.truncated);
344        assert!(capped.text.len() <= 20, "{}", capped.text.len());
345        assert!(capped.text.is_char_boundary(capped.text.len()));
346        assert!(!capped.text.starts_with('\n'));
347    }
348
349    #[test]
350    fn marker_cap_respects_tiny_threshold_and_long_marker() {
351        let capped = cap_head_tail_with_marker("abcdef", 5, 0, 0, "[very long recovery marker]");
352
353        assert!(capped.truncated);
354        assert!(capped.text.len() <= 5, "{}", capped.text.len());
355        assert!(capped.text.is_char_boundary(capped.text.len()));
356        assert!(!capped.text.starts_with('\n'));
357    }
358
359    #[test]
360    fn marker_cap_accounts_for_marker_when_body_is_under_threshold() {
361        let capped = cap_head_tail_with_marker("0123456789", 10, 10, 0, "[x]");
362
363        assert!(capped.truncated);
364        assert!(capped.text.len() <= 10, "{}", capped.text.len());
365        assert!(capped.text.contains("[x]"));
366    }
367
368    #[test]
369    fn completion_cap_success_is_tail_only_and_small() {
370        // A successful task's reminder preview keeps a short tail — no head.
371        // Regression: the uniform 4 KiB head+tail cap flooded completion
372        // reminders with build noise (~1K tokens per task).
373        let input = format!("HEAD-NOISE\n{}\nFINAL SUMMARY LINE", "x".repeat(8_000));
374        let capped = cap_completion_output(&input, true);
375        assert!(capped.truncated);
376        assert!(
377            capped.text.len() <= COMPLETION_SUCCESS_PREVIEW_BYTES,
378            "{}",
379            capped.text.len()
380        );
381        assert!(capped.text.ends_with("FINAL SUMMARY LINE"));
382        assert!(!capped.text.contains("HEAD-NOISE"));
383    }
384
385    #[test]
386    fn completion_cap_failure_keeps_head_and_larger_tail() {
387        // A failed task keeps a small head (command banner / first error) and
388        // a meaningful tail (tracebacks land at the end).
389        let input = format!(
390            "FIRST-ERROR-CONTEXT\n{}\nTraceback: ModuleNotFoundError",
391            "y".repeat(8_000)
392        );
393        let capped = cap_completion_output(&input, false);
394        assert!(capped.truncated);
395        assert!(
396            capped.text.len() <= COMPLETION_FAILURE_PREVIEW_BYTES,
397            "{}",
398            capped.text.len()
399        );
400        assert!(capped.text.starts_with("FIRST-ERROR-CONTEXT"));
401        assert!(capped.text.ends_with("Traceback: ModuleNotFoundError"));
402        // Failure budget is larger than success budget but far below the old 4 KiB.
403        const {
404            assert!(COMPLETION_FAILURE_PREVIEW_BYTES > COMPLETION_SUCCESS_PREVIEW_BYTES);
405            assert!(COMPLETION_FAILURE_PREVIEW_BYTES < 4 * 1024);
406        }
407    }
408
409    #[test]
410    fn completion_cap_passes_short_output_through_untouched() {
411        let input = "ok: 12 tests passed";
412        for exit_ok in [true, false] {
413            let capped = cap_completion_output(input, exit_ok);
414            assert!(!capped.truncated);
415            assert_eq!(capped.text, input);
416        }
417    }
418}