bamboo_tools/
output_manager.rs1use std::io;
8use std::path::PathBuf;
9
10use bamboo_compression::counter::TokenCounter;
11use bamboo_compression::TiktokenTokenCounter;
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: TiktokenTokenCounter,
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: TiktokenTokenCounter::default(),
44 }
45 }
46
47 pub fn with_defaults() -> Self {
52 let artifacts_dir = bamboo_infrastructure::paths::bamboo_dir().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();
224 estimated.min(u32::MAX as f64) as u32
225 };
226
227 artifacts.push(ArtifactRef {
228 id,
229 path,
230 tool_call_id,
231 full_token_count,
232 });
233 }
234 }
235 }
236
237 Ok(artifacts)
238 }
239
240 pub async fn delete_artifact(&self, artifact_id: &str) -> io::Result<bool> {
242 let filename = format!("{}.txt", artifact_id);
243 let path = self.artifacts_dir.join(&filename);
244
245 if path.exists() {
246 tokio::fs::remove_file(&path).await?;
247 Ok(true)
248 } else {
249 Ok(false)
250 }
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use bamboo_compression::TiktokenTokenCounter;
258 use tempfile::tempdir;
259
260 #[tokio::test]
261 async fn cap_small_result_returns_as_is() {
262 let dir = tempdir().unwrap();
263 let manager = ToolOutputManager::new(dir.path(), 100);
264
265 let result = "Small result".to_string();
266 let (capped, artifact) = manager
267 .cap_tool_result("call_1", result.clone())
268 .await
269 .unwrap();
270
271 assert_eq!(capped, result);
272 assert!(artifact.is_none());
273 }
274
275 #[tokio::test]
276 async fn cap_large_result_stores_artifact() {
277 let dir = tempdir().unwrap();
278 let manager = ToolOutputManager::new(dir.path(), 100);
279
280 let result = "x".repeat(1000);
282 let (capped, artifact) = manager
283 .cap_tool_result("call_1", result.clone())
284 .await
285 .unwrap();
286
287 assert!(capped.len() < result.len());
289 assert!(artifact.is_some());
290
291 let artifact = artifact.unwrap();
292 assert_eq!(artifact.tool_call_id, "call_1");
293 assert!(artifact.path.exists());
294
295 let retrieved = manager.retrieve_artifact(&artifact.id).await.unwrap();
297 assert!(retrieved.is_some());
298 assert_eq!(retrieved.unwrap(), result);
299 }
300
301 #[tokio::test]
302 async fn cap_large_result_keeps_inline_output_within_budget() {
303 let dir = tempdir().unwrap();
304 let manager = ToolOutputManager::new(dir.path(), 100);
305
306 let result = "x".repeat(10_000);
308 let (capped, artifact) = manager
309 .cap_tool_result("call_budget", result)
310 .await
311 .unwrap();
312
313 assert!(artifact.is_some());
314
315 let counter = TiktokenTokenCounter::default();
316 let capped_token_count = counter.count_text(&capped);
317 assert!(
318 capped_token_count <= 100,
319 "inline output exceeded budget: {capped_token_count} > 100"
320 );
321 }
322
323 #[tokio::test]
324 async fn list_artifacts_includes_tool_call_id_and_token_count() {
325 let dir = tempdir().unwrap();
326 let manager = ToolOutputManager::new(dir.path(), 50);
327
328 let result = "x".repeat(1_000);
329 let (_capped, artifact) = manager
330 .cap_tool_result("call_list_123", result)
331 .await
332 .unwrap();
333 let artifact = artifact.unwrap();
334
335 let artifacts = manager.list_artifacts().await.unwrap();
336 let listed = artifacts.into_iter().find(|a| a.id == artifact.id).unwrap();
337
338 assert_eq!(listed.tool_call_id, "call_list_123");
339 assert!(listed.full_token_count > 0);
340 assert!(listed.path.exists());
341 }
342
343 #[test]
344 fn truncate_preserves_word_boundary() {
345 let dir = tempdir().unwrap();
346 let manager = ToolOutputManager::new(dir.path(), 100);
347
348 let text = "This is a sentence with multiple words to truncate properly.";
349 let truncated = manager.truncate_to_token_limit(text, 10);
350
351 assert!(!truncated.ends_with("sen"));
353 assert!(truncated.ends_with("..."));
354 }
355}