Skip to main content

atomcode_core/tool/
result_store.rs

1use std::collections::hash_map::DefaultHasher;
2use std::hash::{Hash, Hasher};
3use std::path::PathBuf;
4
5use crate::tool::ToolResult;
6
7/// A lightweight reference to a tool result whose full output lives on disk.
8/// Stored in conversation messages instead of the full output to save memory/tokens.
9#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
10pub struct ToolResultRef {
11    pub call_id: String,
12    /// Hex hash of the full output (content-addressed key).
13    pub hash: String,
14    /// One-line summary for compact context representation.
15    pub summary: String,
16    /// Byte length of the full output.
17    pub byte_size: usize,
18    pub success: bool,
19}
20
21/// Content-addressed disk cache for tool result outputs.
22///
23/// Stores full tool outputs on disk so that conversation messages can hold
24/// lightweight `ToolResultRef` instead of multi-KB strings.
25pub struct ToolResultStore {
26    cache_dir: PathBuf,
27}
28
29impl ToolResultStore {
30    pub fn new(cache_dir: PathBuf) -> Self {
31        let _ = std::fs::create_dir_all(&cache_dir);
32        Self { cache_dir }
33    }
34
35    /// Default cache directory: `$ATOMCODE_HOME/tool_cache/`
36    pub fn default_dir() -> PathBuf {
37        crate::config::Config::config_dir().join("tool_cache")
38    }
39
40    /// Store a tool result on disk and return a lightweight reference.
41    pub fn store(&self, result: &ToolResult) -> ToolResultRef {
42        let hash = content_hash(&result.output);
43        let path = self.cache_dir.join(format!("{}.txt", hash));
44
45        // Write only if not already cached (content-addressed = idempotent).
46        if !path.exists() {
47            let _ = std::fs::write(&path, &result.output);
48        }
49
50        ToolResultRef {
51            call_id: result.call_id.clone(),
52            hash,
53            summary: make_summary(&result.output, result.success),
54            byte_size: result.output.len(),
55            success: result.success,
56        }
57    }
58
59    /// Load the full output from disk. Returns None if the cache entry is missing.
60    pub fn load(&self, ref_: &ToolResultRef) -> Option<String> {
61        let path = self.cache_dir.join(format!("{}.txt", ref_.hash));
62        std::fs::read_to_string(&path).ok()
63    }
64
65    /// Reconstruct a full `ToolResult` from a ref by loading from disk.
66    /// Falls back to the summary if the cache entry is gone.
67    pub fn inflate(&self, ref_: &ToolResultRef) -> ToolResult {
68        let output = self.load(ref_).unwrap_or_else(|| ref_.summary.clone());
69        ToolResult {
70            call_id: ref_.call_id.clone(),
71            output,
72            success: ref_.success,
73        }
74    }
75
76    /// Remove all cached files. Used for cleanup / testing.
77    pub fn clear(&self) {
78        if let Ok(entries) = std::fs::read_dir(&self.cache_dir) {
79            for entry in entries.flatten() {
80                let _ = std::fs::remove_file(entry.path());
81            }
82        }
83    }
84}
85
86/// Produce a hex-encoded hash of the content using the std default hasher.
87/// Not cryptographic, but sufficient for content-addressing within a local cache.
88fn content_hash(content: &str) -> String {
89    let mut hasher = DefaultHasher::new();
90    content.hash(&mut hasher);
91    format!("{:016x}", hasher.finish())
92}
93
94/// Generate a one-line summary of tool output.
95fn make_summary(output: &str, success: bool) -> String {
96    let first_line = output
97        .lines()
98        .next()
99        .unwrap_or(if success { "OK" } else { "Error" });
100    if success {
101        if first_line.chars().count() > 100 {
102            format!("{}...", first_line.chars().take(97).collect::<String>())
103        } else {
104            first_line.to_string()
105        }
106    } else {
107        let trimmed = if first_line.chars().count() > 80 {
108            format!("{}...", first_line.chars().take(77).collect::<String>())
109        } else {
110            first_line.to_string()
111        };
112        format!("FAILED: {}", trimmed)
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::tool::ToolResult;
120
121    fn temp_store() -> (ToolResultStore, tempfile::TempDir) {
122        let dir = tempfile::tempdir().unwrap();
123        let store = ToolResultStore::new(dir.path().to_path_buf());
124        (store, dir)
125    }
126
127    #[test]
128    fn test_store_and_load() {
129        let (store, _dir) = temp_store();
130        let result = ToolResult {
131            call_id: "c1".into(),
132            output: "hello world\nsecond line".into(),
133            success: true,
134        };
135        let ref_ = store.store(&result);
136        assert_eq!(ref_.call_id, "c1");
137        assert!(ref_.success);
138        assert_eq!(ref_.byte_size, result.output.len());
139        assert_eq!(ref_.summary, "hello world");
140
141        let loaded = store.load(&ref_).unwrap();
142        assert_eq!(loaded, "hello world\nsecond line");
143    }
144
145    #[test]
146    fn test_store_idempotent() {
147        let (store, _dir) = temp_store();
148        let result = ToolResult {
149            call_id: "c1".into(),
150            output: "same content".into(),
151            success: true,
152        };
153        let ref1 = store.store(&result);
154        let ref2 = store.store(&result);
155        assert_eq!(ref1.hash, ref2.hash);
156    }
157
158    #[test]
159    fn test_inflate_fallback_to_summary() {
160        let (store, _dir) = temp_store();
161        let ref_ = ToolResultRef {
162            call_id: "c1".into(),
163            hash: "nonexistent_hash".into(),
164            summary: "fallback summary".into(),
165            byte_size: 100,
166            success: true,
167        };
168        let inflated = store.inflate(&ref_);
169        assert_eq!(inflated.output, "fallback summary");
170    }
171
172    #[test]
173    fn test_failure_summary() {
174        let (store, _dir) = temp_store();
175        let result = ToolResult {
176            call_id: "c1".into(),
177            output: "command not found: xyz".into(),
178            success: false,
179        };
180        let ref_ = store.store(&result);
181        assert_eq!(ref_.summary, "FAILED: command not found: xyz");
182    }
183
184    #[test]
185    fn test_long_output_summary_truncation() {
186        let (store, _dir) = temp_store();
187        let long_line = "x".repeat(200);
188        let result = ToolResult {
189            call_id: "c1".into(),
190            output: long_line,
191            success: true,
192        };
193        let ref_ = store.store(&result);
194        assert!(ref_.summary.chars().count() <= 100);
195        assert!(ref_.summary.ends_with("..."));
196    }
197
198    #[test]
199    fn test_clear() {
200        let (store, _dir) = temp_store();
201        let result = ToolResult {
202            call_id: "c1".into(),
203            output: "data".into(),
204            success: true,
205        };
206        let ref_ = store.store(&result);
207        assert!(store.load(&ref_).is_some());
208        store.clear();
209        assert!(store.load(&ref_).is_none());
210    }
211}