behest_runtime/
tool_output.rs1use std::fmt::Write;
11use std::path::PathBuf;
12
13use serde::{Deserialize, Serialize};
14
15pub const DEFAULT_MAX_LINES: usize = 2_000;
17pub const DEFAULT_MAX_BYTES: usize = 50 * 1024; #[derive(Debug, Clone, Serialize, Deserialize)]
22#[non_exhaustive]
23pub struct ToolOutputConfig {
24 pub max_lines: Option<usize>,
26 pub max_bytes: Option<usize>,
28 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#[derive(Debug, Clone)]
44pub struct TruncationResult {
45 pub text: String,
47 pub was_truncated: bool,
49 pub original_lines: usize,
51 pub original_bytes: usize,
53 pub saved_path: Option<PathBuf>,
55}
56
57#[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 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 let line_budget = max_lines.min(original_lines);
88 let result_text = if line_budget > 4 {
90 let head_lines = line_budget.div_ceil(2);
91 let tail_lines = line_budget / 2; 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 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 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 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
179fn 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 let result_lines: Vec<&str> = result.text.lines().collect();
236 assert!(result_lines.len() < 20, "should be significantly truncated");
237
238 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}