ai_agent/
tool_result_storage.rs1use std::fs;
9use std::path::PathBuf;
10
11pub const DEFAULT_MAX_RESULT_SIZE_CHARS: usize = 50_000;
13
14pub const PREVIEW_SIZE_BYTES: usize = 2_000;
16
17pub const MAX_TOOL_RESULTS_PER_MESSAGE_CHARS: usize = 200_000;
19
20pub 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 if content.is_empty() {
33 return (format!("({} completed with no output)", tool_name), false);
34 }
35
36 if content.len() <= threshold {
38 return (content.to_string(), false);
39 }
40
41 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 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
87fn 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 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 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
118pub 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 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 &content[..limit]
140 };
141
142 Preview {
143 text: truncated.to_string(),
144 has_more: true,
145 }
146}
147
148pub 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
168pub struct PersistedToolResult {
170 pub filepath: String,
171 pub original_size: usize,
172 pub preview: String,
173 pub has_more: bool,
174}
175
176pub 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); let preview = generate_preview(&content);
226 assert!(preview.has_more);
227 assert!(preview.text.len() <= PREVIEW_SIZE_BYTES);
228 }
229}