Skip to main content

bamboo_agent/agent/tools/
output_manager.rs

1//! Tool output management for preventing large tool results from consuming the token budget.
2//!
3//! When tool results are too large, they are capped and stored as artifacts,
4//! with a reference returned to the agent so it can retrieve the full content
5//! when needed.
6
7use std::io;
8use std::path::PathBuf;
9
10use crate::agent::core::budget::counter::TokenCounter;
11use crate::agent::core::budget::HeuristicTokenCounter;
12
13/// Reference to a stored artifact (full tool output stored externally).
14#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
15pub struct ArtifactRef {
16    /// Unique identifier for the artifact
17    pub id: String,
18    /// Original tool call ID
19    pub tool_call_id: String,
20    /// Path to the stored artifact file
21    pub path: PathBuf,
22    /// Token count of the full content
23    pub full_token_count: u32,
24}
25
26/// Manager for capping and storing large tool outputs.
27#[derive(Debug)]
28pub struct ToolOutputManager {
29    /// Directory to store artifacts
30    artifacts_dir: PathBuf,
31    /// Maximum inline tokens for tool results
32    max_inline_tokens: u32,
33    /// Token counter
34    counter: HeuristicTokenCounter,
35}
36
37impl ToolOutputManager {
38    /// Create a new tool output manager.
39    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    /// Create with default settings.
48    ///
49    /// Uses `~/.bamboo/artifacts` as the storage directory and 1000 tokens as the limit.
50    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    /// Cap a tool result if it exceeds the token limit.
57    ///
58    /// Returns a tuple of (capped_content, optional_artifact_ref).
59    /// If the result fits within the budget, returns (result, None).
60    /// If the result is too large, returns (truncated_result, Some(artifact_ref)).
61    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 within budget, return as-is
69        if token_count <= self.max_inline_tokens {
70            return Ok((result, None));
71        }
72
73        // Store full result as artifact
74        let artifact = self
75            .store_artifact(tool_call_id, &result, token_count)
76            .await?;
77
78        // Result is too large - truncate and add a short notice referencing the artifact id.
79        // The notice itself costs tokens, so we reserve budget for it up-front.
80        let notice = self.build_truncation_notice(token_count, &artifact.id);
81        let notice_token_count = self.counter.count_text(&notice);
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        // Due to rounding in the heuristic counter, ensure the final string fits the budget.
92        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    /// Builds the truncation notice appended to capped tool output.
106    ///
107    /// Keep this intentionally short because it competes with the inline token budget.
108    fn build_truncation_notice(&self, full_token_count: u32, artifact_id: &str) -> String {
109        // NOTE: We only include the artifact id here (not the full path) to keep token usage low
110        // and avoid leaking local filesystem paths into the model context. A future
111        // `retrieve_artifact` tool can use this id to fetch the full content.
112        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        // Extreme edge case: even the shortest notice doesn't fit. Return a truncated notice so
127        // `cap_tool_result` can still satisfy the inline token constraint.
128        self.truncate_to_token_limit(&candidates[2], self.max_inline_tokens)
129    }
130
131    /// Truncate text to fit within a token budget.
132    fn truncate_to_token_limit(&self, text: &str, max_tokens: u32) -> String {
133        // Rough estimate: each token is about 4 characters
134        // Use a conservative estimate to ensure we stay under the limit
135        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        // Try to truncate at a natural boundary (newline or space)
142        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    /// Store the full result as an artifact file.
151    async fn store_artifact(
152        &self,
153        tool_call_id: &str,
154        content: &str,
155        token_count: u32,
156    ) -> io::Result<ArtifactRef> {
157        // Ensure artifacts directory exists
158        tokio::fs::create_dir_all(&self.artifacts_dir).await?;
159
160        // Generate unique artifact ID
161        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        // Write content to file
166        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    /// Retrieve a stored artifact by ID.
177    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    /// List all stored artifacts.
190    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                    // Best-effort: recover the original tool_call_id from the id format
206                    // `{tool_call_id}_{unix_timestamp}`.
207                    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                    // For typical artifact sizes, compute tokens from file contents for accuracy.
214                    // For very large artifacts, avoid reading the entire file during listing and
215                    // fall back to a byte-based estimate.
216                    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                        // HeuristicTokenCounter defaults: chars/4 + 10% safety margin (ceil).
223                        // Using bytes as a proxy for chars intentionally overestimates for
224                        // non-ASCII content, which is acceptable for a conservative estimate.
225                        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    /// Delete an artifact by ID.
243    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        // Create a large result (more than 100 tokens)
283        let result = "x".repeat(1000);
284        let (capped, artifact) = manager
285            .cap_tool_result("call_1", result.clone())
286            .await
287            .unwrap();
288
289        // Should be truncated
290        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        // Should be able to retrieve full content
298        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        // Create a large result to ensure truncation.
309        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        // Should end at a space or newline, not mid-word
354        assert!(!truncated.ends_with("sen"));
355        assert!(truncated.ends_with("..."));
356    }
357}