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;
8pub const COMPLETION_OUTPUT_PREVIEW_BYTES: usize = 4 * 1024;
9pub const COMPLETION_OUTPUT_HEAD_BYTES: usize = 2 * 1024;
10pub const COMPLETION_OUTPUT_TAIL_BYTES: usize = 2 * 1024;
11
12pub const RAW_PASSTHROUGH_CAP_BYTES: usize = 50 * 1024;
13pub const RAW_PASSTHROUGH_HEAD_BYTES: usize = 20 * 1024;
14pub const RAW_PASSTHROUGH_TAIL_BYTES: usize = 30 * 1024;
15
16pub const STRUCTURED_OUTPUT_CAP_BYTES: usize = 50 * 1024;
17
18pub const COMPRESS_INPUT_CAP_BYTES: usize = 10 * 1024 * 1024;
19pub const COMPRESS_INPUT_HEAD_BYTES: usize = 4 * 1024 * 1024;
20pub const COMPRESS_INPUT_TAIL_BYTES: usize = 6 * 1024 * 1024;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct CappedText {
24    pub text: String,
25    pub truncated: bool,
26}
27
28pub fn cap_final_output(input: &str) -> CappedText {
29    cap_head_tail(
30        input,
31        FINAL_OUTPUT_CAP_BYTES,
32        FINAL_OUTPUT_HEAD_BYTES,
33        FINAL_OUTPUT_TAIL_BYTES,
34    )
35}
36
37pub fn cap_final_output_with_marker(input: &str, marker: &str) -> CappedText {
38    cap_head_tail_with_marker(
39        input,
40        FINAL_OUTPUT_CAP_BYTES,
41        FINAL_OUTPUT_HEAD_BYTES,
42        FINAL_OUTPUT_TAIL_BYTES,
43        marker,
44    )
45}
46
47pub fn cap_completion_output(input: &str) -> CappedText {
48    cap_head_tail(
49        input,
50        COMPLETION_OUTPUT_PREVIEW_BYTES,
51        COMPLETION_OUTPUT_HEAD_BYTES,
52        COMPLETION_OUTPUT_TAIL_BYTES,
53    )
54}
55
56pub fn cap_completion_output_with_marker(input: &str, marker: &str) -> CappedText {
57    cap_head_tail_with_marker(
58        input,
59        COMPLETION_OUTPUT_PREVIEW_BYTES,
60        COMPLETION_OUTPUT_HEAD_BYTES,
61        COMPLETION_OUTPUT_TAIL_BYTES,
62        marker,
63    )
64}
65
66pub fn cap_head_tail(
67    input: &str,
68    threshold_bytes: usize,
69    keep_head: usize,
70    keep_tail: usize,
71) -> CappedText {
72    if input.len() <= threshold_bytes {
73        return CappedText {
74            text: input.to_string(),
75            truncated: false,
76        };
77    }
78
79    let head_end = floor_char_boundary(input, keep_head.min(input.len()));
80    let mut tail_start = ceil_char_boundary(input, input.len().saturating_sub(keep_tail));
81
82    if head_end >= tail_start {
83        return CappedText {
84            text: input.to_string(),
85            truncated: false,
86        };
87    }
88
89    let marker_prefix_len = if head_end == 0 || input[..head_end].ends_with('\n') {
90        0
91    } else {
92        1
93    };
94    loop {
95        let truncated_bytes = tail_start - head_end;
96        let marker_len = marker_prefix_len
97            + "...<truncated ".len()
98            + truncated_bytes.to_string().len()
99            + " bytes>...\n".len();
100        let max_tail = threshold_bytes.saturating_sub(head_end + marker_len);
101        let adjusted_tail_start = ceil_char_boundary(input, input.len().saturating_sub(max_tail));
102        if adjusted_tail_start <= tail_start {
103            break;
104        }
105        tail_start = adjusted_tail_start;
106        if head_end >= tail_start {
107            return CappedText {
108                text: input.to_string(),
109                truncated: false,
110            };
111        }
112    }
113
114    let truncated_bytes = tail_start - head_end;
115    let mut output = String::with_capacity(threshold_bytes.min(input.len()));
116    output.push_str(&input[..head_end]);
117    if !output.ends_with('\n') {
118        output.push('\n');
119    }
120    output.push_str("...<truncated ");
121    output.push_str(&truncated_bytes.to_string());
122    output.push_str(" bytes>...\n");
123    output.push_str(&input[tail_start..]);
124
125    CappedText {
126        text: output,
127        truncated: true,
128    }
129}
130
131pub fn cap_head_tail_with_marker(
132    input: &str,
133    threshold_bytes: usize,
134    keep_head: usize,
135    keep_tail: usize,
136    marker: &str,
137) -> CappedText {
138    if marker.is_empty() {
139        return cap_head_tail(input, threshold_bytes, keep_head, keep_tail);
140    }
141    if input.len() <= threshold_bytes {
142        let with_marker = append_marker_line(input, marker);
143        if with_marker.len() <= threshold_bytes {
144            return CappedText {
145                text: with_marker,
146                truncated: true,
147            };
148        }
149    }
150
151    let mut head_budget = keep_head.min(input.len());
152    let mut tail_budget = keep_tail.min(input.len());
153    let mut seen_budgets = Vec::new();
154
155    for _ in 0..8 {
156        let head_end = floor_char_boundary(input, head_budget);
157        let marker_prefix_len = if head_end == 0 || input[..head_end].ends_with('\n') {
158            0
159        } else {
160            1
161        };
162        let marker_len = marker_prefix_len + marker.len() + 1;
163        let available = threshold_bytes.saturating_sub(marker_len);
164        let next_head = keep_head.min(available).min(input.len());
165        let next_tail = keep_tail
166            .min(available.saturating_sub(next_head))
167            .min(input.len().saturating_sub(next_head));
168
169        if next_head == head_budget && next_tail == tail_budget {
170            break;
171        }
172        if seen_budgets.contains(&(next_head, next_tail)) {
173            if next_head.saturating_add(next_tail) < head_budget.saturating_add(tail_budget) {
174                head_budget = next_head;
175                tail_budget = next_tail;
176            }
177            break;
178        }
179        seen_budgets.push((head_budget, tail_budget));
180        head_budget = next_head;
181        tail_budget = next_tail;
182    }
183
184    let head_end = floor_char_boundary(input, head_budget);
185    let tail_start = ceil_char_boundary(input, input.len().saturating_sub(tail_budget));
186    let tail_start = if head_end >= tail_start {
187        input.len()
188    } else {
189        tail_start
190    };
191
192    CappedText {
193        text: marker_capped_output(input, head_end, tail_start, marker, threshold_bytes),
194        truncated: true,
195    }
196}
197
198fn append_marker_line(input: &str, marker: &str) -> String {
199    if input.is_empty() {
200        return marker.to_string();
201    }
202    let mut output = input.trim_end().to_string();
203    output.push('\n');
204    output.push_str(marker);
205    output
206}
207
208fn marker_capped_output(
209    input: &str,
210    head_end: usize,
211    tail_start: usize,
212    marker: &str,
213    threshold_bytes: usize,
214) -> String {
215    let output = marker_output(input, head_end, tail_start, marker);
216    if output.len() <= threshold_bytes {
217        return output;
218    }
219
220    let separator_len = usize::from(head_end > 0 && !input[..head_end].ends_with('\n'));
221    let marker_line_len = marker.len().saturating_add(1);
222    let tail_budget = threshold_bytes.saturating_sub(head_end + separator_len + marker_line_len);
223    let adjusted_tail_start = ceil_char_boundary(input, input.len().saturating_sub(tail_budget));
224    let output = marker_output(input, head_end, tail_start.max(adjusted_tail_start), marker);
225    if output.len() <= threshold_bytes {
226        return output;
227    }
228
229    // If the marker itself still fits, prefer preserving it and trimming all head
230    // content before resorting to a marker-only hard cap.
231    let tail_budget = threshold_bytes.saturating_sub(marker_line_len);
232    let adjusted_tail_start = ceil_char_boundary(input, input.len().saturating_sub(tail_budget));
233    let output = marker_output(input, 0, adjusted_tail_start, marker);
234    if output.len() <= threshold_bytes {
235        return output;
236    }
237
238    marker[..floor_char_boundary(marker, threshold_bytes.min(marker.len()))].to_string()
239}
240
241fn marker_output(input: &str, head_end: usize, tail_start: usize, marker: &str) -> String {
242    let mut output = String::new();
243    output.push_str(&input[..head_end]);
244    if head_end > 0 && !output.ends_with('\n') {
245        output.push('\n');
246    }
247    output.push_str(marker);
248    if tail_start < input.len() {
249        output.push('\n');
250        output.push_str(&input[tail_start..]);
251    }
252    output
253}
254
255pub fn quote_path(path: &str) -> String {
256    // The path is shown to the agent as `read "<path>"` for the AFT read tool
257    // (and, for the Unix-only `tail -n +N` form, a shell hint). The read tool
258    // consumes the path literally, so we must NOT backslash-double: on Windows
259    // `C:\Users\foo` would become `C:\\Users\\foo` and the agent would copy a
260    // corrupted path. Only escape an embedded `"` so the surrounding quotes
261    // can't be broken (paths containing `"` are illegal on Windows and rare on
262    // Unix). Realistic Unix paths have no backslashes, so this is a no-op there.
263    let escaped = path.replace('"', "\\\"");
264    format!("\"{escaped}\"")
265}
266
267pub fn json_output_pointer(total_bytes: u64, path: &str) -> String {
268    let kb = total_bytes.div_ceil(1024);
269    format!(
270        "[JSON output {kb} KB; full output: read {}]",
271        quote_path(path)
272    )
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    #[test]
280    fn quote_path_preserves_windows_backslashes() {
281        // The recovery marker shows the agent `read "<path>"` for the AFT read
282        // tool, which consumes the path literally. A Windows path must survive
283        // verbatim (no backslash doubling), otherwise the agent copies a broken
284        // path. Regression for the cross-platform CI failure.
285        assert_eq!(
286            quote_path(r"C:\Users\foo\stdout"),
287            r#""C:\Users\foo\stdout""#
288        );
289        assert_eq!(quote_path(r"C:\a\b"), r#""C:\a\b""#);
290        assert_eq!(quote_path("/tmp/out"), r#""/tmp/out""#);
291        // An embedded double-quote is still escaped so the wrapping can't break.
292        assert_eq!(quote_path(r#"a"b"#), r#""a\"b""#);
293    }
294
295    #[test]
296    fn head_tail_cap_respects_utf8_boundaries() {
297        let input = format!("{}{}", "🦀".repeat(4_000), "tail");
298        let capped = cap_head_tail(&input, 128, 64, 64);
299        assert!(capped.truncated);
300        assert!(capped.text.is_char_boundary(capped.text.len()));
301        assert!(capped.text.contains("...<truncated "));
302        assert!(capped.text.len() <= 128);
303        assert!(capped.text.ends_with("tail"));
304    }
305
306    #[test]
307    fn marker_cap_respects_newline_boundary_oscillation() {
308        let capped = cap_head_tail_with_marker("01234567\nabcdef", 20, 15, 0, "123456789");
309
310        assert!(capped.truncated);
311        assert!(capped.text.len() <= 20, "{}", capped.text.len());
312        assert!(capped.text.is_char_boundary(capped.text.len()));
313        assert!(!capped.text.starts_with('\n'));
314    }
315
316    #[test]
317    fn marker_cap_respects_tiny_threshold_and_long_marker() {
318        let capped = cap_head_tail_with_marker("abcdef", 5, 0, 0, "[very long recovery marker]");
319
320        assert!(capped.truncated);
321        assert!(capped.text.len() <= 5, "{}", capped.text.len());
322        assert!(capped.text.is_char_boundary(capped.text.len()));
323        assert!(!capped.text.starts_with('\n'));
324    }
325
326    #[test]
327    fn marker_cap_accounts_for_marker_when_body_is_under_threshold() {
328        let capped = cap_head_tail_with_marker("0123456789", 10, 10, 0, "[x]");
329
330        assert!(capped.truncated);
331        assert!(capped.text.len() <= 10, "{}", capped.text.len());
332        assert!(capped.text.contains("[x]"));
333    }
334}