Skip to main content

ai_agent/
tool_result_storage.rs

1// Source: /data/home/swei/claudecode/openclaudecode/src/utils/toolResultStorage.ts
2//! Tool result size management and disk persistence for large outputs.
3//!
4//! Translated from TypeScript toolResultStorage.ts.
5//! Large tool results are persisted to disk to avoid bloating the API request,
6//! and a preview with a path reference is returned instead.
7
8use std::fs;
9use std::path::PathBuf;
10
11/// Default max result size in characters before persistence is considered.
12pub const DEFAULT_MAX_RESULT_SIZE_CHARS: usize = 50_000;
13
14/// Preview size in bytes for persisted results.
15pub const PREVIEW_SIZE_BYTES: usize = 2_000;
16
17/// Maximum total tool result size per API message before replacement.
18pub const MAX_TOOL_RESULTS_PER_MESSAGE_CHARS: usize = 200_000;
19
20/// Persist a large tool result to disk, returning a preview with path.
21///
22/// Returns the processed content and whether it was persisted.
23pub fn maybe_persist_large_result(
24    content: &str,
25    tool_use_id: &str,
26    tool_name: &str,
27    project_dir: Option<&str>,
28    session_id: Option<&str>,
29    threshold: usize,
30) -> (String, bool) {
31    // Empty content guard: return a friendly message to prevent model stop sequences
32    if content.is_empty() {
33        return (format!("({} completed with no output)", tool_name), false);
34    }
35
36    // Size check
37    if content.len() <= threshold {
38        return (content.to_string(), false);
39    }
40
41    // Persist to disk
42    let result = match (project_dir, session_id) {
43        (Some(pd), Some(sid)) => persist_tool_result(content, tool_use_id, pd, sid).map_err(|_| ()),
44        _ => Err(()),
45    };
46
47    match result {
48        Ok(persisted) => {
49            let preview = generate_preview(content);
50            let wrapped = format!(
51                "<persisted-output>\n\
52                Output too large ({} chars). Full output saved to: {}\n\n\
53                Preview (first {} bytes, sorted by newline):\n\
54                {}\n\
55                {}\n\
56                </persisted-output>",
57                content.len(),
58                persisted.filepath,
59                PREVIEW_SIZE_BYTES,
60                preview.text,
61                if persisted.has_more {
62                    format!(
63                        "... [{} more bytes] ...",
64                        persisted.original_size - PREVIEW_SIZE_BYTES
65                    )
66                } else {
67                    String::new()
68                },
69            );
70            (wrapped, true)
71        }
72        Err(_) => {
73            // If persistence fails, just truncate
74            let truncated = if content.len() > threshold * 2 {
75                format!(
76                    "{}... [truncated]",
77                    &content[..threshold.min(content.len())]
78                )
79            } else {
80                content.to_string()
81            };
82            (truncated, false)
83        }
84    }
85}
86
87/// Persist a tool result to disk.
88fn persist_tool_result(
89    content: &str,
90    tool_use_id: &str,
91    project_dir: &str,
92    session_id: &str,
93) -> Result<PersistedToolResult, std::io::Error> {
94    let tool_results_dir = PathBuf::from(project_dir)
95        .join(".ai")
96        .join("tool-results")
97        .join(session_id);
98
99    // Create directory if it doesn't exist
100    fs::create_dir_all(&tool_results_dir)?;
101
102    let filepath = tool_results_dir.join(format!("{}.txt", tool_use_id));
103    let original_size = content.len();
104
105    // Write with exclusive create flag to avoid race conditions
106    fs::write(&filepath, content)?;
107
108    let preview = generate_preview(content);
109
110    Ok(PersistedToolResult {
111        filepath: filepath.to_string_lossy().to_string(),
112        original_size,
113        preview: preview.text,
114        has_more: preview.has_more,
115    })
116}
117
118/// Generate a preview of the content, truncating at a newline boundary.
119pub fn generate_preview(content: &str) -> Preview {
120    let limit = PREVIEW_SIZE_BYTES;
121    if content.len() <= limit {
122        return Preview {
123            text: content.to_string(),
124            has_more: false,
125        };
126    }
127
128    // Find a good truncation point at a newline within 50% of the limit
129    let search_start = limit / 2;
130    let truncated = if let Some(last_newline) = content[search_start..limit]
131        .rfind('\n')
132        .map(|i| i + search_start)
133    {
134        &content[..last_newline]
135    } else if let Some(newline) = content[..limit].rfind('\n') {
136        &content[..newline]
137    } else {
138        // No newline found, just truncate at limit
139        &content[..limit]
140    };
141
142    Preview {
143        text: truncated.to_string(),
144        has_more: true,
145    }
146}
147
148/// Process a tool result, applying size management.
149pub fn process_tool_result(
150    content: &str,
151    tool_name: &str,
152    tool_use_id: &str,
153    project_dir: Option<&str>,
154    session_id: Option<&str>,
155    max_result_size: Option<usize>,
156) -> (String, bool) {
157    let threshold = max_result_size.unwrap_or(DEFAULT_MAX_RESULT_SIZE_CHARS);
158    maybe_persist_large_result(
159        content,
160        tool_use_id,
161        tool_name,
162        project_dir,
163        session_id,
164        threshold,
165    )
166}
167
168/// Result of persisting a tool result to disk.
169pub struct PersistedToolResult {
170    pub filepath: String,
171    pub original_size: usize,
172    pub preview: String,
173    pub has_more: bool,
174}
175
176/// Preview with truncation indicator.
177pub struct Preview {
178    pub text: String,
179    pub has_more: bool,
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn test_small_content_not_persisted() {
188        let (content, was_persisted) = maybe_persist_large_result(
189            "small content",
190            "tool1",
191            "Bash",
192            Some("/tmp"),
193            Some("sess1"),
194            50_000,
195        );
196        assert_eq!(content, "small content");
197        assert!(!was_persisted);
198    }
199
200    #[test]
201    fn test_empty_content_returns_message() {
202        let (content, _) =
203            maybe_persist_large_result("", "tool1", "Bash", Some("/tmp"), Some("sess1"), 100);
204        assert_eq!(content, "(Bash completed with no output)");
205    }
206
207    #[test]
208    fn test_generate_preview_small() {
209        let preview = generate_preview("short");
210        assert_eq!(preview.text, "short");
211        assert!(!preview.has_more);
212    }
213
214    #[test]
215    fn test_generate_preview_large() {
216        let content = "a".repeat(5000);
217        let preview = generate_preview(&content);
218        assert!(preview.has_more);
219        assert!(preview.text.len() <= PREVIEW_SIZE_BYTES);
220    }
221
222    #[test]
223    fn test_generate_preview_with_newline() {
224        let content = "line1\nline2\nline3\n".repeat(200); // ~3400 chars
225        let preview = generate_preview(&content);
226        assert!(preview.has_more);
227        assert!(preview.text.len() <= PREVIEW_SIZE_BYTES);
228    }
229}