Skip to main content

bamboo_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 bamboo_compression::counter::TokenCounter;
11use bamboo_compression::TiktokenTokenCounter;
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: TiktokenTokenCounter,
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: TiktokenTokenCounter::default(),
44        }
45    }
46
47    /// Create with default settings.
48    ///
49    /// Uses the Bamboo data directory's `artifacts/` folder as the storage directory and
50    /// 1000 tokens as the limit.
51    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    /// 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                        // Fallback byte-based estimate for very large artifacts.
223                        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    /// Delete an artifact by ID.
241    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        // Create a large result (more than 100 tokens)
281        let result = "x".repeat(1000);
282        let (capped, artifact) = manager
283            .cap_tool_result("call_1", result.clone())
284            .await
285            .unwrap();
286
287        // Should be truncated
288        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        // Should be able to retrieve full content
296        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        // Create a large result to ensure truncation.
307        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        // Should end at a space or newline, not mid-word
352        assert!(!truncated.ends_with("sen"));
353        assert!(truncated.ends_with("..."));
354    }
355}