Skip to main content

agent_code_lib/services/
output_store.rs

1//! Output persistence for large tool results.
2//!
3//! When a tool produces output larger than the inline threshold,
4//! it's persisted to disk and a reference is returned instead.
5//! This prevents large outputs from bloating the context window.
6
7use std::path::{Path, PathBuf};
8
9use crate::services::secret_masker;
10
11/// Maximum inline output size (64KB). Larger results are persisted.
12const INLINE_THRESHOLD: usize = 64 * 1024;
13
14/// Persist large output to disk and return a summary reference.
15///
16/// If the content is under the threshold, returns it unchanged.
17/// Otherwise, writes to the output store and returns a truncated
18/// version with a file path reference.
19pub fn persist_if_large(content: &str, tool_name: &str, tool_use_id: &str) -> String {
20    persist_if_large_in(&output_store_dir(), content, tool_name, tool_use_id)
21}
22
23/// Variant of [`persist_if_large`] that writes into an explicit
24/// directory. Used by tests to avoid touching the real cache dir.
25pub(crate) fn persist_if_large_in(
26    store_dir: &Path,
27    content: &str,
28    _tool_name: &str,
29    tool_use_id: &str,
30) -> String {
31    if content.len() <= INLINE_THRESHOLD {
32        return content.to_string();
33    }
34
35    let _ = std::fs::create_dir_all(store_dir);
36
37    let filename = format!("{tool_use_id}.txt");
38    let path = store_dir.join(&filename);
39
40    // Mask secrets at the persistence boundary. The returned preview
41    // is kept unmasked for in-memory agent use; only the on-disk copy
42    // is sanitized.
43    let persisted = secret_masker::mask(content);
44
45    match std::fs::write(&path, &persisted) {
46        Ok(()) => {
47            let preview = &content[..INLINE_THRESHOLD.min(content.len())];
48            format!(
49                "{preview}\n\n(Output truncated. Full result ({} bytes) saved to {})",
50                content.len(),
51                path.display()
52            )
53        }
54        Err(_) => {
55            // Can't persist — truncate inline.
56            let preview = &content[..INLINE_THRESHOLD.min(content.len())];
57            format!(
58                "{preview}\n\n(Output truncated: {} bytes total)",
59                content.len()
60            )
61        }
62    }
63}
64
65/// Read a persisted output by tool_use_id.
66pub fn read_persisted(tool_use_id: &str) -> Option<String> {
67    let path = output_store_dir().join(format!("{tool_use_id}.txt"));
68    std::fs::read_to_string(path).ok()
69}
70
71/// Clean up old persisted outputs (older than 24 hours).
72pub fn cleanup_old_outputs() {
73    let dir = output_store_dir();
74    if !dir.is_dir() {
75        return;
76    }
77
78    let cutoff = std::time::SystemTime::now() - std::time::Duration::from_secs(24 * 60 * 60);
79
80    if let Ok(entries) = std::fs::read_dir(&dir) {
81        for entry in entries.flatten() {
82            if let Ok(meta) = entry.metadata()
83                && let Ok(modified) = meta.modified()
84                && modified < cutoff
85            {
86                let _ = std::fs::remove_file(entry.path());
87            }
88        }
89    }
90}
91
92fn output_store_dir() -> PathBuf {
93    dirs::cache_dir()
94        .unwrap_or_else(|| PathBuf::from("/tmp"))
95        .join("agent-code")
96        .join("tool-results")
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn persist_if_large_passes_small_content_through_unchanged() {
105        let dir = tempfile::tempdir().unwrap();
106        let small = "tiny output with api_key=irrelevant_for_small_content";
107        // Smaller than the threshold — returned unchanged, nothing written.
108        let out = persist_if_large_in(dir.path(), small, "Bash", "tool-1");
109        assert_eq!(out, small);
110        assert!(
111            std::fs::read_dir(dir.path())
112                .map(|mut it| it.next().is_none())
113                .unwrap_or(true),
114            "small content should not write to disk",
115        );
116    }
117
118    #[test]
119    fn persist_if_large_masks_secrets_on_disk_but_not_in_preview() {
120        let dir = tempfile::tempdir().unwrap();
121        let aws_key = "AKIAIOSFODNN7EXAMPLE";
122        // Build a payload larger than INLINE_THRESHOLD with a secret
123        // embedded well before the truncation point.
124        let mut content = String::with_capacity(INLINE_THRESHOLD + 1024);
125        content.push_str("prefix noise ");
126        content.push_str(aws_key);
127        content.push_str(" more noise ");
128        while content.len() <= INLINE_THRESHOLD {
129            content.push_str("filler ");
130        }
131
132        let preview = persist_if_large_in(dir.path(), &content, "Bash", "tool-big");
133
134        // Preview (in-memory return) keeps the raw secret so the agent
135        // can still reason about it in the current turn.
136        assert!(
137            preview.contains(aws_key),
138            "preview should keep raw secret for in-memory use",
139        );
140        assert!(preview.contains("Output truncated"));
141
142        // On-disk copy must have the secret scrubbed.
143        let disk_path = dir.path().join("tool-big.txt");
144        assert!(disk_path.exists(), "persisted file not created");
145        let on_disk = std::fs::read_to_string(&disk_path).unwrap();
146        assert!(
147            !on_disk.contains(aws_key),
148            "raw secret found on disk: {on_disk}",
149        );
150        assert!(on_disk.contains("[REDACTED:aws_access_key]"));
151    }
152
153    #[test]
154    fn persist_if_large_redacts_generic_credential_on_disk() {
155        let dir = tempfile::tempdir().unwrap();
156        let secret = "supersecretproductiontoken1234567890";
157        let assignment = format!("DATABASE_PASSWORD={secret}");
158        let mut content = assignment.clone();
159        while content.len() <= INLINE_THRESHOLD {
160            content.push_str(" padding padding padding padding padding ");
161        }
162
163        let _ = persist_if_large_in(dir.path(), &content, "Bash", "tool-db");
164
165        let disk_path = dir.path().join("tool-db.txt");
166        let on_disk = std::fs::read_to_string(&disk_path).unwrap();
167        assert!(!on_disk.contains(secret));
168        assert!(on_disk.contains("[REDACTED:credential]"));
169    }
170
171    #[test]
172    fn persist_if_large_at_exact_threshold_passes_through() {
173        // Content of exactly INLINE_THRESHOLD bytes must be returned
174        // unchanged (the guard is `content.len() <= INLINE_THRESHOLD`).
175        // Regression-proofs the boundary so a future refactor from
176        // `<=` to `<` would be caught here.
177        let dir = tempfile::tempdir().unwrap();
178        let content = "a".repeat(INLINE_THRESHOLD);
179        let out = persist_if_large_in(dir.path(), &content, "Bash", "tool-boundary-eq");
180        assert_eq!(out.len(), INLINE_THRESHOLD);
181        assert_eq!(out, content);
182        assert!(
183            std::fs::read_dir(dir.path())
184                .map(|mut it| it.next().is_none())
185                .unwrap_or(true),
186            "content at exact threshold should not write to disk",
187        );
188    }
189
190    #[test]
191    fn persist_if_large_at_threshold_plus_one_writes_to_disk() {
192        // One byte past the threshold must trigger a disk write.
193        let dir = tempfile::tempdir().unwrap();
194        let content = "a".repeat(INLINE_THRESHOLD + 1);
195        let preview = persist_if_large_in(dir.path(), &content, "Bash", "tool-boundary-plus-one");
196        assert!(preview.contains("Output truncated"));
197        assert!(dir.path().join("tool-boundary-plus-one.txt").exists());
198    }
199}