Skip to main content

microclaw_engine/internal/storage/db/
tool_cache.rs

1use super::*;
2
3/// A row returned from the tool result cache lookup.
4#[derive(Debug, Clone)]
5pub struct CachedToolResult {
6    pub tool_name: String,
7    pub content: String,
8    pub is_error: bool,
9    pub metadata_json: Option<String>,
10    pub created_at: String,
11    pub expires_at: String,
12}
13
14/// Metadata for a stored tool-result artifact.
15#[derive(Debug, Clone)]
16pub struct ToolArtifactMeta {
17    pub artifact_id: String,
18    pub chat_id: i64,
19    pub tool_name: String,
20    pub total_chars: i64,
21    pub created_at: String,
22    pub expires_at: String,
23}
24
25impl Database {
26    /// Look up a cached tool result by key; returns `None` if missing or
27    /// past its TTL. Passing `now` as an RFC3339 string lets callers
28    /// control "now" deterministically in tests.
29    pub fn get_cached_tool_result(
30        &self,
31        cache_key: &str,
32        now: &str,
33    ) -> Result<Option<CachedToolResult>, MicroClawError> {
34        let conn = self.lock_conn();
35        let row = conn
36            .query_row(
37                "SELECT tool_name, result_content, is_error, metadata_json, created_at, expires_at
38                 FROM tool_result_cache
39                 WHERE cache_key = ?1 AND expires_at > ?2",
40                params![cache_key, now],
41                |r| {
42                    Ok(CachedToolResult {
43                        tool_name: r.get(0)?,
44                        content: r.get(1)?,
45                        is_error: r.get::<_, i64>(2)? != 0,
46                        metadata_json: r.get::<_, Option<String>>(3)?,
47                        created_at: r.get(4)?,
48                        expires_at: r.get(5)?,
49                    })
50                },
51            )
52            .optional()?;
53        Ok(row)
54    }
55
56    /// Insert or replace a cached tool result. Only non-error results
57    /// should be cached in practice; the `is_error` flag is exposed so
58    /// callers can choose a policy.
59    pub fn put_cached_tool_result(
60        &self,
61        cache_key: &str,
62        tool_name: &str,
63        content: &str,
64        is_error: bool,
65        metadata_json: Option<&str>,
66        expires_at: &str,
67    ) -> Result<(), MicroClawError> {
68        let conn = self.lock_conn();
69        let now = chrono::Utc::now().to_rfc3339();
70        conn.execute(
71            "INSERT INTO tool_result_cache
72                (cache_key, tool_name, result_content, is_error, metadata_json, created_at, expires_at)
73             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
74             ON CONFLICT(cache_key) DO UPDATE SET
75                tool_name = excluded.tool_name,
76                result_content = excluded.result_content,
77                is_error = excluded.is_error,
78                metadata_json = excluded.metadata_json,
79                created_at = excluded.created_at,
80                expires_at = excluded.expires_at",
81            params![cache_key, tool_name, content, is_error as i64, metadata_json, now, expires_at],
82        )?;
83        Ok(())
84    }
85
86    /// Delete all expired cache rows. Returns number of rows deleted.
87    pub fn prune_tool_result_cache(&self, now: &str) -> Result<usize, MicroClawError> {
88        let conn = self.lock_conn();
89        let n = conn.execute(
90            "DELETE FROM tool_result_cache WHERE expires_at <= ?1",
91            params![now],
92        )?;
93        Ok(n)
94    }
95
96    /// Persist a tool-result artifact (full content) so the agent can fetch
97    /// slices later via `fetch_artifact`. The caller is responsible for
98    /// generating a unique `artifact_id`.
99    pub fn save_tool_artifact(
100        &self,
101        artifact_id: &str,
102        chat_id: i64,
103        tool_name: &str,
104        content: &str,
105        expires_at: &str,
106    ) -> Result<(), MicroClawError> {
107        let conn = self.lock_conn();
108        let now = chrono::Utc::now().to_rfc3339();
109        let total_chars = content.chars().count() as i64;
110        conn.execute(
111            "INSERT INTO tool_result_artifacts
112                (artifact_id, chat_id, tool_name, content, total_chars, created_at, expires_at)
113             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
114            params![
115                artifact_id,
116                chat_id,
117                tool_name,
118                content,
119                total_chars,
120                now,
121                expires_at
122            ],
123        )?;
124        Ok(())
125    }
126
127    /// Fetch a character-range slice of a stored artifact. Returns
128    /// `(meta, slice, returned_chars)` if the artifact exists and is not
129    /// expired. `offset` and `length` are interpreted as Unicode code
130    /// points to keep the cap predictable across multi-byte content.
131    pub fn get_tool_artifact_slice(
132        &self,
133        artifact_id: &str,
134        offset: usize,
135        length: usize,
136        now: &str,
137    ) -> Result<Option<(ToolArtifactMeta, String)>, MicroClawError> {
138        let conn = self.lock_conn();
139        let row = conn
140            .query_row(
141                "SELECT artifact_id, chat_id, tool_name, content, total_chars, created_at, expires_at
142                 FROM tool_result_artifacts
143                 WHERE artifact_id = ?1 AND expires_at > ?2",
144                params![artifact_id, now],
145                |r| {
146                    let content: String = r.get(3)?;
147                    Ok((
148                        ToolArtifactMeta {
149                            artifact_id: r.get(0)?,
150                            chat_id: r.get(1)?,
151                            tool_name: r.get(2)?,
152                            total_chars: r.get(4)?,
153                            created_at: r.get(5)?,
154                            expires_at: r.get(6)?,
155                        },
156                        content,
157                    ))
158                },
159            )
160            .optional()?;
161        let Some((meta, content)) = row else {
162            return Ok(None);
163        };
164        let slice: String = content.chars().skip(offset).take(length).collect();
165        Ok(Some((meta, slice)))
166    }
167
168    /// Delete expired artifact rows. Returns number of rows deleted.
169    pub fn prune_tool_artifacts(&self, now: &str) -> Result<usize, MicroClawError> {
170        let conn = self.lock_conn();
171        let n = conn.execute(
172            "DELETE FROM tool_result_artifacts WHERE expires_at <= ?1",
173            params![now],
174        )?;
175        Ok(n)
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use crate::internal::storage::db::test_support::*;
182
183    #[test]
184    fn test_tool_artifact_save_and_slice() {
185        let (db, dir) = test_db();
186        let now = chrono::Utc::now();
187        let expires = (now + chrono::Duration::hours(1)).to_rfc3339();
188        let body: String = "0123456789".repeat(20); // 200 chars
189        db.save_tool_artifact("art_x", 42, "bash", &body, &expires)
190            .unwrap();
191
192        // First slice from offset 0
193        let (meta, slice) = db
194            .get_tool_artifact_slice("art_x", 0, 50, &now.to_rfc3339())
195            .unwrap()
196            .expect("artifact present");
197        assert_eq!(meta.chat_id, 42);
198        assert_eq!(meta.tool_name, "bash");
199        assert_eq!(meta.total_chars, 200);
200        assert_eq!(slice.chars().count(), 50);
201        assert!(body.starts_with(&slice));
202
203        // Slice past the end clamps to remaining
204        let (_, tail) = db
205            .get_tool_artifact_slice("art_x", 190, 50, &now.to_rfc3339())
206            .unwrap()
207            .unwrap();
208        assert_eq!(tail.chars().count(), 10);
209
210        // Expired artifact returns None
211        let future = (now + chrono::Duration::hours(2)).to_rfc3339();
212        let missing = db.get_tool_artifact_slice("art_x", 0, 10, &future).unwrap();
213        assert!(missing.is_none());
214
215        // Prune removes expired rows
216        let pruned = db.prune_tool_artifacts(&future).unwrap();
217        assert_eq!(pruned, 1);
218        cleanup(&dir);
219    }
220}