Skip to main content

microclaw_engine/internal/tool_runtime/
tool_cache.rs

1//! Tool result cache helpers.
2//!
3//! Cache-key derivation and opt-in tool list. The actual read/write goes
4//! through `crate::internal::storage::db::Database::{get,put}_cached_tool_result`,
5//! which keeps the storage contract in one place.
6
7use std::collections::HashMap;
8use std::time::Duration;
9
10use sha2::{Digest, Sha256};
11
12/// Tools allowed to participate in caching, with default TTL.
13/// Expanding this list is a config decision; read-only network/tool calls
14/// are the prime candidates.
15pub fn default_ttls() -> HashMap<&'static str, Duration> {
16    let mut m = HashMap::new();
17    m.insert("web_fetch", Duration::from_secs(15 * 60));
18    m.insert("web_search", Duration::from_secs(10 * 60));
19    m.insert("osv_check", Duration::from_secs(60 * 60));
20    m.insert("describe_image", Duration::from_secs(60 * 60));
21    m.insert("session_search", Duration::from_secs(60));
22    m
23}
24
25/// Normalize a JSON value so structurally-equivalent inputs produce the
26/// same cache key. Objects are key-sorted; arrays keep order. Auth
27/// context fields are removed so identical requests from different
28/// callers collide intentionally (tool cache is per-input, not per-user).
29pub fn normalize_input_for_key(input: &serde_json::Value) -> serde_json::Value {
30    match input {
31        serde_json::Value::Object(map) => {
32            let mut keys: Vec<_> = map
33                .keys()
34                .filter(|k| !k.starts_with("__microclaw_"))
35                .cloned()
36                .collect();
37            keys.sort();
38            let mut out = serde_json::Map::new();
39            for k in keys {
40                if let Some(v) = map.get(&k) {
41                    out.insert(k, normalize_input_for_key(v));
42                }
43            }
44            serde_json::Value::Object(out)
45        }
46        serde_json::Value::Array(arr) => {
47            serde_json::Value::Array(arr.iter().map(normalize_input_for_key).collect())
48        }
49        other => other.clone(),
50    }
51}
52
53/// Compute the cache key for (`tool_name`, `input`). Uses SHA-256 hex
54/// over a canonical JSON encoding.
55pub fn cache_key(tool_name: &str, input: &serde_json::Value) -> String {
56    let canonical = serde_json::to_string(&normalize_input_for_key(input)).unwrap_or_default();
57    let mut hasher = Sha256::new();
58    hasher.update(tool_name.as_bytes());
59    hasher.update(b":");
60    hasher.update(canonical.as_bytes());
61    format!("{:x}", hasher.finalize())
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use serde_json::json;
68
69    #[test]
70    fn object_key_order_is_irrelevant() {
71        let a = json!({"b": 1, "a": 2});
72        let b = json!({"a": 2, "b": 1});
73        assert_eq!(cache_key("t", &a), cache_key("t", &b));
74    }
75
76    #[test]
77    fn auth_context_is_stripped() {
78        let with_auth = json!({"q": "x", "__microclaw_auth": {"caller_chat_id": 1}});
79        let without = json!({"q": "x"});
80        assert_eq!(cache_key("t", &with_auth), cache_key("t", &without));
81    }
82
83    #[test]
84    fn different_tools_collide_differently() {
85        let v = json!({"q": "x"});
86        assert_ne!(cache_key("a", &v), cache_key("b", &v));
87    }
88
89    #[test]
90    fn array_order_matters() {
91        let a = json!([1, 2, 3]);
92        let b = json!([3, 2, 1]);
93        assert_ne!(cache_key("t", &a), cache_key("t", &b));
94    }
95}