Skip to main content

behest_runtime/
tool_output.rs

1//! Tool output truncation with head+tail sampling.
2//!
3//! When tool output exceeds configured limits, the content is truncated
4//! using a head+tail sampling strategy: the first half of allowed lines
5//! and the last half are preserved, with a truncation marker in between.
6//! Full output is written to a file for later inspection.
7//!
8//! Ported from OpenCode V2's `ToolOutputStore.bound()`.
9
10use std::fmt::Write;
11use std::path::PathBuf;
12
13use serde::{Deserialize, Serialize};
14
15/// Maximum lines before truncation applies (default).
16pub const DEFAULT_MAX_LINES: usize = 2_000;
17/// Maximum bytes before truncation applies (default).
18pub const DEFAULT_MAX_BYTES: usize = 50 * 1024; // 50 KB
19
20/// Configuration for tool output truncation.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[non_exhaustive]
23pub struct ToolOutputConfig {
24    /// Maximum lines before truncation. `None` disables line-based truncation.
25    pub max_lines: Option<usize>,
26    /// Maximum bytes before truncation. `None` disables byte-based truncation.
27    pub max_bytes: Option<usize>,
28    /// Directory to save full truncated output files. `None` disables file saving.
29    pub output_dir: Option<PathBuf>,
30}
31
32impl Default for ToolOutputConfig {
33    fn default() -> Self {
34        Self {
35            max_lines: Some(DEFAULT_MAX_LINES),
36            max_bytes: Some(DEFAULT_MAX_BYTES),
37            output_dir: None,
38        }
39    }
40}
41
42/// Result of tool output truncation.
43#[derive(Debug, Clone)]
44pub struct TruncationResult {
45    /// The truncated output text (or original if within limits).
46    pub text: String,
47    /// Whether truncation was applied.
48    pub was_truncated: bool,
49    /// Number of original lines (0 if unknown).
50    pub original_lines: usize,
51    /// Number of original bytes.
52    pub original_bytes: usize,
53    /// Path to the saved full output file, if any.
54    pub saved_path: Option<PathBuf>,
55}
56
57/// Applies head+tail sampling truncation to tool output.
58///
59/// Strategy: preserves the first half and the last half of the allowed
60/// line count, inserting a truncation marker in between. Falls back to
61/// byte-based prefix/suffix if byte limits are stricter.
62#[must_use]
63pub fn truncate_output(
64    output: &str,
65    config: &ToolOutputConfig,
66    identifier: Option<&str>,
67) -> TruncationResult {
68    let original_bytes = output.len();
69    let lines: Vec<&str> = output.lines().collect();
70    let original_lines = lines.len();
71
72    let max_lines = config.max_lines.unwrap_or(usize::MAX);
73    let max_bytes = config.max_bytes.unwrap_or(usize::MAX);
74
75    // Check if truncation is needed
76    if original_lines <= max_lines && original_bytes <= max_bytes {
77        return TruncationResult {
78            text: output.to_owned(),
79            was_truncated: false,
80            original_lines,
81            original_bytes,
82            saved_path: None,
83        };
84    }
85
86    // Determine line budget
87    let line_budget = max_lines.min(original_lines);
88    // Minimum 4 lines to apply head+tail sampling
89    let result_text = if line_budget > 4 {
90        let head_lines = line_budget.div_ceil(2);
91        let tail_lines = line_budget / 2; // floor division
92
93        let head: Vec<&str> = lines.iter().take(head_lines).copied().collect();
94        let tail: Vec<&str> = lines
95            .iter()
96            .rev()
97            .take(tail_lines)
98            .copied()
99            .collect::<Vec<_>>()
100            .into_iter()
101            .rev()
102            .collect();
103
104        let marker = truncation_marker(
105            original_lines,
106            original_bytes,
107            head_lines + 1..original_lines - tail_lines,
108            identifier,
109            config.output_dir.as_ref(),
110        );
111
112        let mut sampled = head.join("\n");
113        sampled.push('\n');
114        sampled.push_str(&marker);
115        if !tail.is_empty() {
116            sampled.push('\n');
117            sampled.push_str(&tail.join("\n"));
118        }
119        sampled
120    } else {
121        // Too few lines for head+tail — just truncate
122        let prefix: String = output.chars().take(max_bytes.min(original_bytes)).collect();
123        let marker = truncation_marker(
124            original_lines,
125            original_bytes,
126            0..original_lines,
127            identifier,
128            config.output_dir.as_ref(),
129        );
130        format!("{prefix}\n{marker}")
131    };
132
133    // Apply byte limit to the result
134    let result_text = if result_text.len() > max_bytes {
135        let head_bytes = max_bytes * 3 / 4;
136        let tail_bytes = max_bytes - head_bytes;
137        let head: String = result_text.chars().take(head_bytes).collect();
138        let tail: String = result_text
139            .chars()
140            .rev()
141            .take(tail_bytes)
142            .collect::<String>()
143            .chars()
144            .rev()
145            .collect();
146        let marker = format!(
147            "\n... output truncated ({original_lines} lines, {original_bytes} bytes total) ...\n"
148        );
149        format!("{head}{marker}{tail}")
150    } else {
151        result_text
152    };
153
154    // Save full output to file
155    let saved_path = if let (Some(dir), Some(id)) = (&config.output_dir, identifier) {
156        let path = dir.join(format!("tool_{id}"));
157        if let Err(e) = std::fs::create_dir_all(dir) {
158            tracing::warn!(error = %e, "failed to create tool output directory");
159            None
160        } else if let Err(e) = std::fs::write(&path, output) {
161            tracing::warn!(error = %e, "failed to write full tool output");
162            None
163        } else {
164            Some(path)
165        }
166    } else {
167        None
168    };
169
170    TruncationResult {
171        text: result_text,
172        was_truncated: true,
173        original_lines,
174        original_bytes,
175        saved_path,
176    }
177}
178
179/// Generates the truncation marker text.
180fn truncation_marker(
181    total_lines: usize,
182    total_bytes: usize,
183    _omitted_range: std::ops::Range<usize>,
184    identifier: Option<&str>,
185    output_dir: Option<&PathBuf>,
186) -> String {
187    let omitted_lines = total_lines.saturating_sub(if total_lines > 4 {
188        total_lines.min(DEFAULT_MAX_LINES).div_ceil(2) * 2
189    } else {
190        total_lines.min(1)
191    });
192    let mut marker = format!(
193        "\n... output truncated ({omitted_lines} lines omitted, {total_bytes} bytes total) ..."
194    );
195
196    if let (Some(dir), Some(id)) = (output_dir, identifier) {
197        let path = dir.join(format!("tool_{id}"));
198        let _ = write!(
199            marker,
200            "\nFull output saved to: {path}",
201            path = path.display()
202        );
203    }
204
205    marker
206}
207
208#[cfg(test)]
209#[allow(clippy::unwrap_used)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn no_truncation_when_within_limits() {
215        let config = ToolOutputConfig::default();
216        let output = "line1\nline2\nline3";
217        let result = truncate_output(output, &config, None);
218        assert!(!result.was_truncated);
219        assert_eq!(result.text, output);
220    }
221
222    #[test]
223    fn truncates_by_lines() {
224        let config = ToolOutputConfig {
225            max_lines: Some(6),
226            max_bytes: None,
227            output_dir: None,
228        };
229        let lines: Vec<String> = (0..100).map(|i| format!("line{i}")).collect();
230        let output = lines.join("\n");
231        let result = truncate_output(&output, &config, None);
232        assert!(result.was_truncated);
233
234        // Should have head+tail with marker
235        let result_lines: Vec<&str> = result.text.lines().collect();
236        assert!(result_lines.len() < 20, "should be significantly truncated");
237
238        // First and last lines should be preserved
239        assert!(result.text.contains("line0"));
240        assert!(result.text.contains("line99"));
241    }
242
243    #[test]
244    fn truncates_by_bytes() {
245        let config = ToolOutputConfig {
246            max_lines: None,
247            max_bytes: Some(100),
248            output_dir: None,
249        };
250        let output = "x".repeat(10_000);
251        let result = truncate_output(&output, &config, None);
252        assert!(result.was_truncated);
253        assert!(
254            result.text.len() <= 200,
255            "result should be near the byte limit"
256        );
257    }
258
259    #[test]
260    fn includes_truncation_marker() {
261        let config = ToolOutputConfig {
262            max_lines: Some(6),
263            max_bytes: None,
264            output_dir: None,
265        };
266        let lines: Vec<String> = (0..100).map(|i| format!("line{i}")).collect();
267        let output = lines.join("\n");
268        let result = truncate_output(&output, &config, None);
269        assert!(result.text.contains("output truncated"));
270    }
271
272    #[test]
273    fn empty_output_passes_through() {
274        let config = ToolOutputConfig::default();
275        let result = truncate_output("", &config, None);
276        assert!(!result.was_truncated);
277        assert!(result.text.is_empty());
278    }
279
280    #[test]
281    fn single_line_within_limit() {
282        let config = ToolOutputConfig::default();
283        let output = "single line";
284        let result = truncate_output(output, &config, None);
285        assert!(!result.was_truncated);
286        assert_eq!(result.text, output);
287    }
288}