Skip to main content

opendev_tools_impl/
truncation.rs

1//! Tool output truncation with temp file overflow.
2//!
3//! When tool output exceeds size limits (lines or bytes), the full output is
4//! saved to a temp file under `~/.opendev/tool-output/` and the agent receives
5//! a truncated preview plus the file path for follow-up reads.
6//!
7//! Mirrors OpenCode's `Truncate` system.
8
9use std::path::{Path, PathBuf};
10
11/// Maximum number of lines before truncation.
12pub const MAX_LINES: usize = 2000;
13
14/// Maximum output size in bytes before truncation (50 KB).
15pub const MAX_BYTES: usize = 50 * 1024;
16
17/// Retention period for temp output files (7 days).
18const RETENTION_SECS: u64 = 7 * 24 * 60 * 60;
19
20/// Maximum size for overflow files (1 MB). Prevents a single tool call
21/// from writing unbounded output to disk.
22const MAX_OVERFLOW_BYTES: usize = 1_024 * 1_024;
23
24/// Direction from which to keep lines when truncating.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum TruncateDirection {
27    /// Keep the first N lines (default).
28    Head,
29    /// Keep the last N lines.
30    Tail,
31}
32
33/// Result of a truncation attempt.
34#[derive(Debug, Clone)]
35pub struct TruncateResult {
36    /// The (possibly truncated) content to return to the agent.
37    pub content: String,
38    /// Whether the output was truncated.
39    pub truncated: bool,
40    /// Path to the full output file, if truncated.
41    pub output_path: Option<PathBuf>,
42}
43
44/// Get the directory for storing truncated tool output.
45pub fn output_dir() -> PathBuf {
46    dirs::home_dir()
47        .unwrap_or_else(|| PathBuf::from("/tmp"))
48        .join(".opendev")
49        .join("tool-output")
50}
51
52/// Truncate tool output if it exceeds size limits.
53///
54/// If the output fits within `max_lines` and `max_bytes`, returns it as-is.
55/// Otherwise, saves the full output to a temp file and returns a truncated
56/// preview with a hint about how to access the full content.
57pub fn truncate_output(
58    text: &str,
59    max_lines: Option<usize>,
60    max_bytes: Option<usize>,
61    direction: TruncateDirection,
62) -> TruncateResult {
63    let max_lines = max_lines.unwrap_or(MAX_LINES);
64    let max_bytes = max_bytes.unwrap_or(MAX_BYTES);
65    let lines: Vec<&str> = text.lines().collect();
66    let total_bytes = text.len();
67
68    // No truncation needed.
69    if lines.len() <= max_lines && total_bytes <= max_bytes {
70        return TruncateResult {
71            content: text.to_string(),
72            truncated: false,
73            output_path: None,
74        };
75    }
76
77    // Collect lines within limits.
78    let mut kept: Vec<&str> = Vec::new();
79    let mut bytes = 0usize;
80    let mut hit_bytes = false;
81
82    match direction {
83        TruncateDirection::Head => {
84            for (i, line) in lines.iter().enumerate() {
85                if i >= max_lines {
86                    break;
87                }
88                let line_bytes = line.len() + if i > 0 { 1 } else { 0 }; // +1 for \n
89                if bytes + line_bytes > max_bytes {
90                    hit_bytes = true;
91                    break;
92                }
93                kept.push(line);
94                bytes += line_bytes;
95            }
96        }
97        TruncateDirection::Tail => {
98            // Iterate from the end.
99            for (idx, line) in lines.iter().rev().enumerate() {
100                if idx >= max_lines {
101                    break;
102                }
103                let line_bytes = line.len() + if idx > 0 { 1 } else { 0 };
104                if bytes + line_bytes > max_bytes {
105                    hit_bytes = true;
106                    break;
107                }
108                kept.push(line);
109                bytes += line_bytes;
110            }
111            kept.reverse();
112        }
113    }
114
115    let removed = if hit_bytes {
116        total_bytes - bytes
117    } else {
118        lines.len() - kept.len()
119    };
120    let unit = if hit_bytes { "bytes" } else { "lines" };
121    let preview = kept.join("\n");
122
123    // Save full output to temp file.
124    let dir = output_dir();
125    let output_path = match save_overflow(&dir, text) {
126        Ok(p) => Some(p),
127        Err(e) => {
128            tracing::warn!(error = %e, "failed to save truncated tool output");
129            None
130        }
131    };
132
133    let hint = if let Some(ref path) = output_path {
134        format!(
135            "The tool call succeeded but the output was truncated. Full output saved to: {}\n\
136             Use Grep to search the full content or Read with offset/limit to view specific sections.",
137            path.display()
138        )
139    } else {
140        "The tool call succeeded but the output was truncated.".to_string()
141    };
142
143    let content = match direction {
144        TruncateDirection::Head => {
145            format!("{preview}\n\n...{removed} {unit} truncated...\n\n{hint}")
146        }
147        TruncateDirection::Tail => {
148            format!("...{removed} {unit} truncated...\n\n{hint}\n\n{preview}")
149        }
150    };
151
152    TruncateResult {
153        content,
154        truncated: true,
155        output_path,
156    }
157}
158
159/// Save full output to a uniquely-named file in the overflow directory.
160///
161/// If `text` exceeds [`MAX_OVERFLOW_BYTES`], the saved file is itself truncated
162/// (head 75% + tail 25%) to prevent unbounded disk usage.
163fn save_overflow(dir: &Path, text: &str) -> std::io::Result<PathBuf> {
164    std::fs::create_dir_all(dir)?;
165
166    let timestamp = std::time::SystemTime::now()
167        .duration_since(std::time::UNIX_EPOCH)
168        .unwrap_or_default()
169        .as_millis();
170    let id = uuid::Uuid::new_v4().simple().to_string();
171    let filename = format!("tool_{timestamp}_{}", &id[..8]);
172    let filepath = dir.join(filename);
173
174    let to_write = if text.len() > MAX_OVERFLOW_BYTES {
175        let head_size = MAX_OVERFLOW_BYTES * 3 / 4;
176        let tail_size = MAX_OVERFLOW_BYTES - head_size;
177        let head: String = text.chars().take(head_size).collect();
178        let tail: String = text
179            .chars()
180            .rev()
181            .take(tail_size)
182            .collect::<Vec<_>>()
183            .into_iter()
184            .rev()
185            .collect();
186        let omitted = text.len() - head_size - tail_size;
187        format!("{head}\n\n[... {omitted} bytes omitted from overflow file ...]\n\n{tail}")
188    } else {
189        text.to_string()
190    };
191
192    std::fs::write(&filepath, &to_write)?;
193    Ok(filepath)
194}
195
196/// Clean up overflow files older than the retention period (7 days).
197///
198/// Call this periodically (e.g., on startup or hourly) to prevent unbounded
199/// disk usage.
200pub fn cleanup_old_files() {
201    let dir = output_dir();
202    let entries = match std::fs::read_dir(&dir) {
203        Ok(rd) => rd,
204        Err(_) => return, // Directory doesn't exist yet — nothing to clean.
205    };
206
207    let cutoff = std::time::SystemTime::now()
208        .checked_sub(std::time::Duration::from_secs(RETENTION_SECS))
209        .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
210
211    let mut cleaned = 0u32;
212    for entry in entries.flatten() {
213        let name = entry.file_name().to_string_lossy().into_owned();
214        if !name.starts_with("tool_") {
215            continue;
216        }
217        // Check file modification time.
218        if let Ok(meta) = entry.metadata()
219            && let Ok(mtime) = meta.modified()
220            && mtime < cutoff
221            && std::fs::remove_file(entry.path()).is_ok()
222        {
223            cleaned += 1;
224        }
225    }
226    if cleaned > 0 {
227        tracing::debug!(count = cleaned, "cleaned up old tool output files");
228    }
229}
230
231#[cfg(test)]
232#[path = "truncation_tests.rs"]
233mod tests;