bamboo_agent/agent/tools/
output_manager.rs1use std::io;
8use std::path::PathBuf;
9
10use crate::agent::core::budget::counter::TokenCounter;
11use crate::agent::core::budget::HeuristicTokenCounter;
12
13#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
15pub struct ArtifactRef {
16 pub id: String,
18 pub tool_call_id: String,
20 pub path: PathBuf,
22 pub full_token_count: u32,
24}
25
26#[derive(Debug)]
28pub struct ToolOutputManager {
29 artifacts_dir: PathBuf,
31 max_inline_tokens: u32,
33 counter: HeuristicTokenCounter,
35}
36
37impl ToolOutputManager {
38 pub fn new(artifacts_dir: impl Into<PathBuf>, max_inline_tokens: u32) -> Self {
40 Self {
41 artifacts_dir: artifacts_dir.into(),
42 max_inline_tokens,
43 counter: HeuristicTokenCounter::default(),
44 }
45 }
46
47 pub fn with_defaults() -> Self {
51 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
52 let artifacts_dir = home.join(".bamboo").join("artifacts");
53 Self::new(artifacts_dir, 1000)
54 }
55
56 pub async fn cap_tool_result(
62 &self,
63 tool_call_id: &str,
64 result: String,
65 ) -> io::Result<(String, Option<ArtifactRef>)> {
66 let token_count = self.counter.count_text(&result);
67
68 if token_count <= self.max_inline_tokens {
70 return Ok((result, None));
71 }
72
73 let artifact = self
75 .store_artifact(tool_call_id, &result, token_count)
76 .await?;
77
78 let notice = self.build_truncation_notice(token_count, &artifact.id);
81 let notice_token_count = self.counter.count_text(¬ice);
82 let mut content_budget = self.max_inline_tokens.saturating_sub(notice_token_count);
83
84 let mut truncated = if content_budget == 0 {
85 String::new()
86 } else {
87 self.truncate_to_token_limit(&result, content_budget)
88 };
89 let mut capped = format!("{truncated}{notice}");
90
91 while content_budget > 0 && self.counter.count_text(&capped) > self.max_inline_tokens {
93 content_budget = content_budget.saturating_sub(1);
94 truncated = if content_budget == 0 {
95 String::new()
96 } else {
97 self.truncate_to_token_limit(&result, content_budget)
98 };
99 capped = format!("{truncated}{notice}");
100 }
101
102 Ok((capped, Some(artifact)))
103 }
104
105 fn build_truncation_notice(&self, full_token_count: u32, artifact_id: &str) -> String {
109 let candidates = [
113 format!(
114 "\n\n[Output truncated. Full result ({full_token_count} tokens) stored as artifact id '{artifact_id}'.]"
115 ),
116 format!("\n\n[Output truncated. Artifact id '{artifact_id}'.]"),
117 format!("\n\n[Truncated. Artifact '{artifact_id}'.]"),
118 ];
119
120 for candidate in candidates.iter() {
121 if self.counter.count_text(candidate) <= self.max_inline_tokens {
122 return candidate.clone();
123 }
124 }
125
126 self.truncate_to_token_limit(&candidates[2], self.max_inline_tokens)
129 }
130
131 fn truncate_to_token_limit(&self, text: &str, max_tokens: u32) -> String {
133 let max_chars = (max_tokens as f64 * 3.5) as usize;
136
137 if text.len() <= max_chars {
138 return text.to_string();
139 }
140
141 let truncate_at = text[..max_chars]
143 .rfind('\n')
144 .or_else(|| text[..max_chars].rfind(' '))
145 .unwrap_or(max_chars);
146
147 format!("{}...", &text[..truncate_at])
148 }
149
150 async fn store_artifact(
152 &self,
153 tool_call_id: &str,
154 content: &str,
155 token_count: u32,
156 ) -> io::Result<ArtifactRef> {
157 tokio::fs::create_dir_all(&self.artifacts_dir).await?;
159
160 let artifact_id = format!("{}_{}", tool_call_id, chrono::Utc::now().timestamp());
162 let filename = format!("{}.txt", artifact_id);
163 let artifact_path = self.artifacts_dir.join(&filename);
164
165 tokio::fs::write(&artifact_path, content).await?;
167
168 Ok(ArtifactRef {
169 id: artifact_id,
170 tool_call_id: tool_call_id.to_string(),
171 path: artifact_path,
172 full_token_count: token_count,
173 })
174 }
175
176 pub async fn retrieve_artifact(&self, artifact_id: &str) -> io::Result<Option<String>> {
178 let filename = format!("{}.txt", artifact_id);
179 let path = self.artifacts_dir.join(&filename);
180
181 if !path.exists() {
182 return Ok(None);
183 }
184
185 let content = tokio::fs::read_to_string(&path).await?;
186 Ok(Some(content))
187 }
188
189 pub async fn list_artifacts(&self) -> io::Result<Vec<ArtifactRef>> {
191 let mut artifacts = Vec::new();
192
193 if !self.artifacts_dir.exists() {
194 return Ok(artifacts);
195 }
196
197 let mut entries = tokio::fs::read_dir(&self.artifacts_dir).await?;
198 while let Some(entry) = entries.next_entry().await? {
199 let path = entry.path();
200 if path.extension().is_some_and(|ext| ext == "txt") {
201 if let Some(stem) = path.file_stem() {
202 let id = stem.to_string_lossy().to_string();
203 let metadata = tokio::fs::metadata(&path).await?;
204
205 let tool_call_id = id
208 .rsplit_once('_')
209 .and_then(|(prefix, suffix)| suffix.parse::<i64>().ok().map(|_| prefix))
210 .unwrap_or("")
211 .to_string();
212
213 let full_token_count = if metadata.len() <= 1024 * 1024 {
217 match tokio::fs::read_to_string(&path).await {
218 Ok(content) => self.counter.count_text(&content),
219 Err(_) => 0,
220 }
221 } else {
222 let estimated = ((metadata.len() as f64 / 4.0) * 1.1).ceil();
226 estimated.min(u32::MAX as f64) as u32
227 };
228
229 artifacts.push(ArtifactRef {
230 id,
231 path,
232 tool_call_id,
233 full_token_count,
234 });
235 }
236 }
237 }
238
239 Ok(artifacts)
240 }
241
242 pub async fn delete_artifact(&self, artifact_id: &str) -> io::Result<bool> {
244 let filename = format!("{}.txt", artifact_id);
245 let path = self.artifacts_dir.join(&filename);
246
247 if path.exists() {
248 tokio::fs::remove_file(&path).await?;
249 Ok(true)
250 } else {
251 Ok(false)
252 }
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use crate::agent::core::budget::HeuristicTokenCounter;
260 use tempfile::tempdir;
261
262 #[tokio::test]
263 async fn cap_small_result_returns_as_is() {
264 let dir = tempdir().unwrap();
265 let manager = ToolOutputManager::new(dir.path(), 100);
266
267 let result = "Small result".to_string();
268 let (capped, artifact) = manager
269 .cap_tool_result("call_1", result.clone())
270 .await
271 .unwrap();
272
273 assert_eq!(capped, result);
274 assert!(artifact.is_none());
275 }
276
277 #[tokio::test]
278 async fn cap_large_result_stores_artifact() {
279 let dir = tempdir().unwrap();
280 let manager = ToolOutputManager::new(dir.path(), 100);
281
282 let result = "x".repeat(1000);
284 let (capped, artifact) = manager
285 .cap_tool_result("call_1", result.clone())
286 .await
287 .unwrap();
288
289 assert!(capped.len() < result.len());
291 assert!(artifact.is_some());
292
293 let artifact = artifact.unwrap();
294 assert_eq!(artifact.tool_call_id, "call_1");
295 assert!(artifact.path.exists());
296
297 let retrieved = manager.retrieve_artifact(&artifact.id).await.unwrap();
299 assert!(retrieved.is_some());
300 assert_eq!(retrieved.unwrap(), result);
301 }
302
303 #[tokio::test]
304 async fn cap_large_result_keeps_inline_output_within_budget() {
305 let dir = tempdir().unwrap();
306 let manager = ToolOutputManager::new(dir.path(), 100);
307
308 let result = "x".repeat(10_000);
310 let (capped, artifact) = manager
311 .cap_tool_result("call_budget", result)
312 .await
313 .unwrap();
314
315 assert!(artifact.is_some());
316
317 let counter = HeuristicTokenCounter::default();
318 let capped_token_count = counter.count_text(&capped);
319 assert!(
320 capped_token_count <= 100,
321 "inline output exceeded budget: {capped_token_count} > 100"
322 );
323 }
324
325 #[tokio::test]
326 async fn list_artifacts_includes_tool_call_id_and_token_count() {
327 let dir = tempdir().unwrap();
328 let manager = ToolOutputManager::new(dir.path(), 50);
329
330 let result = "x".repeat(1_000);
331 let (_capped, artifact) = manager
332 .cap_tool_result("call_list_123", result)
333 .await
334 .unwrap();
335 let artifact = artifact.unwrap();
336
337 let artifacts = manager.list_artifacts().await.unwrap();
338 let listed = artifacts.into_iter().find(|a| a.id == artifact.id).unwrap();
339
340 assert_eq!(listed.tool_call_id, "call_list_123");
341 assert!(listed.full_token_count > 0);
342 assert!(listed.path.exists());
343 }
344
345 #[test]
346 fn truncate_preserves_word_boundary() {
347 let dir = tempdir().unwrap();
348 let manager = ToolOutputManager::new(dir.path(), 100);
349
350 let text = "This is a sentence with multiple words to truncate properly.";
351 let truncated = manager.truncate_to_token_limit(text, 10);
352
353 assert!(!truncated.ends_with("sen"));
355 assert!(truncated.ends_with("..."));
356 }
357}