Skip to main content

adk_core/
schema_cache.rs

1//! Schema normalization cache for LLM provider adapters.
2//!
3//! Caches normalized schemas keyed by a content hash of the serialized JSON,
4//! avoiding redundant normalization when the same tool schema is encountered
5//! across multiple requests.
6//!
7//! # Example
8//!
9//! ```rust
10//! use adk_core::{SchemaCache, SchemaAdapter, GenericSchemaAdapter};
11//! use serde_json::json;
12//!
13//! let cache = SchemaCache::new();
14//! let adapter = GenericSchemaAdapter;
15//! let schema = json!({"type": "object", "properties": {"name": {"type": "string"}}});
16//!
17//! // First call normalizes and caches
18//! let result1 = cache.get_or_normalize(&schema, &adapter);
19//!
20//! // Second call returns cached value without re-normalizing
21//! let result2 = cache.get_or_normalize(&schema, &adapter);
22//! assert_eq!(result1, result2);
23//! ```
24
25use std::collections::HashMap;
26use std::hash::{DefaultHasher, Hash, Hasher};
27use std::sync::Mutex;
28
29use serde_json::Value;
30
31use crate::SchemaAdapter;
32
33/// A thread-safe cache for normalized JSON Schemas.
34///
35/// Stores normalized schemas keyed by a 64-bit hash of the serialized input schema.
36/// This avoids re-running normalization transforms on unchanged schemas across
37/// repeated `generate_content()` calls.
38///
39/// # Thread Safety
40///
41/// Uses [`std::sync::Mutex`] internally, making it safe to share across threads.
42/// The lock is held only briefly during hash lookup and insertion.
43///
44/// # Placement
45///
46/// Intended to live on model instances so each provider adapter maintains its own
47/// cache of normalized schemas.
48#[derive(Debug, Default)]
49pub struct SchemaCache {
50    entries: Mutex<HashMap<u64, Value>>,
51}
52
53impl SchemaCache {
54    /// Creates a new empty schema cache.
55    ///
56    /// # Example
57    ///
58    /// ```rust
59    /// use adk_core::SchemaCache;
60    ///
61    /// let cache = SchemaCache::new();
62    /// ```
63    pub fn new() -> Self {
64        Self { entries: Mutex::new(HashMap::new()) }
65    }
66
67    /// Returns the normalized schema for the given input, using the cache if available.
68    ///
69    /// If the schema has been normalized before (based on content hash), the cached
70    /// result is returned. Otherwise, `adapter.normalize_schema()` is called and the
71    /// result is stored in the cache.
72    ///
73    /// # Arguments
74    ///
75    /// * `schema` - The raw JSON Schema to normalize.
76    /// * `adapter` - The provider-specific schema adapter to use for normalization.
77    ///
78    /// # Returns
79    ///
80    /// The normalized JSON Schema value.
81    ///
82    /// # Example
83    ///
84    /// ```rust
85    /// use adk_core::{SchemaCache, SchemaAdapter, GenericSchemaAdapter};
86    /// use serde_json::json;
87    ///
88    /// let cache = SchemaCache::new();
89    /// let adapter = GenericSchemaAdapter;
90    /// let schema = json!({"$schema": "draft-07", "type": "string"});
91    ///
92    /// let normalized = cache.get_or_normalize(&schema, &adapter);
93    /// assert!(normalized.get("$schema").is_none());
94    /// ```
95    pub fn get_or_normalize(&self, schema: &Value, adapter: &dyn SchemaAdapter) -> Value {
96        let hash = Self::hash_schema(schema);
97        let mut cache = self.entries.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
98        cache.entry(hash).or_insert_with(|| adapter.normalize_schema(schema.clone())).clone()
99    }
100
101    /// Clears all cached entries.
102    ///
103    /// Call this when the set of tools changes (e.g., MCP server advertises
104    /// updated schemas) to force re-normalization on the next request.
105    ///
106    /// # Example
107    ///
108    /// ```rust
109    /// use adk_core::{SchemaCache, GenericSchemaAdapter};
110    /// use serde_json::json;
111    ///
112    /// let cache = SchemaCache::new();
113    /// let adapter = GenericSchemaAdapter;
114    /// let schema = json!({"type": "string"});
115    ///
116    /// // Populate cache
117    /// cache.get_or_normalize(&schema, &adapter);
118    ///
119    /// // Invalidate all entries
120    /// cache.clear();
121    /// ```
122    pub fn clear(&self) {
123        let mut cache = self.entries.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
124        cache.clear();
125    }
126
127    /// Returns the number of cached entries.
128    pub fn len(&self) -> usize {
129        let cache = self.entries.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
130        cache.len()
131    }
132
133    /// Returns `true` if the cache contains no entries.
134    pub fn is_empty(&self) -> bool {
135        self.len() == 0
136    }
137
138    /// Computes a 64-bit hash of the serialized schema bytes.
139    ///
140    /// Uses `serde_json::to_vec` for deterministic serialization and
141    /// `DefaultHasher` (SipHash) for the hash function.
142    fn hash_schema(schema: &Value) -> u64 {
143        let bytes = serde_json::to_vec(schema).unwrap_or_default();
144        let mut hasher = DefaultHasher::new();
145        bytes.hash(&mut hasher);
146        hasher.finish()
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153    use serde_json::json;
154
155    use crate::GenericSchemaAdapter;
156
157    #[test]
158    fn test_cache_returns_normalized_schema() {
159        let cache = SchemaCache::new();
160        let adapter = GenericSchemaAdapter;
161        let schema = json!({
162            "$schema": "http://json-schema.org/draft-07/schema#",
163            "type": "object",
164            "properties": { "name": { "type": "string" } }
165        });
166
167        let result = cache.get_or_normalize(&schema, &adapter);
168        assert!(result.get("$schema").is_none());
169        assert_eq!(result["type"], "object");
170    }
171
172    #[test]
173    fn test_cache_returns_same_result_on_repeated_calls() {
174        let cache = SchemaCache::new();
175        let adapter = GenericSchemaAdapter;
176        let schema = json!({
177            "type": "object",
178            "properties": { "x": { "type": "integer", "const": 42 } }
179        });
180
181        let first = cache.get_or_normalize(&schema, &adapter);
182        let second = cache.get_or_normalize(&schema, &adapter);
183        assert_eq!(first, second);
184    }
185
186    #[test]
187    fn test_cache_stores_entries() {
188        let cache = SchemaCache::new();
189        let adapter = GenericSchemaAdapter;
190
191        assert!(cache.is_empty());
192        assert_eq!(cache.len(), 0);
193
194        let schema1 = json!({"type": "string"});
195        let schema2 = json!({"type": "number"});
196
197        cache.get_or_normalize(&schema1, &adapter);
198        assert_eq!(cache.len(), 1);
199
200        cache.get_or_normalize(&schema2, &adapter);
201        assert_eq!(cache.len(), 2);
202
203        // Same schema doesn't add a new entry
204        cache.get_or_normalize(&schema1, &adapter);
205        assert_eq!(cache.len(), 2);
206    }
207
208    #[test]
209    fn test_cache_clear_removes_all_entries() {
210        let cache = SchemaCache::new();
211        let adapter = GenericSchemaAdapter;
212
213        cache.get_or_normalize(&json!({"type": "string"}), &adapter);
214        cache.get_or_normalize(&json!({"type": "number"}), &adapter);
215        assert_eq!(cache.len(), 2);
216
217        cache.clear();
218        assert!(cache.is_empty());
219    }
220
221    #[test]
222    fn test_cache_different_schemas_produce_different_entries() {
223        let cache = SchemaCache::new();
224        let adapter = GenericSchemaAdapter;
225
226        let schema_a = json!({"type": "string", "format": "hostname"});
227        let schema_b = json!({"type": "string", "format": "email"});
228
229        let result_a = cache.get_or_normalize(&schema_a, &adapter);
230        let result_b = cache.get_or_normalize(&schema_b, &adapter);
231
232        // "hostname" is stripped, "email" is preserved
233        assert!(result_a.get("format").is_none());
234        assert_eq!(result_b["format"], "email");
235        assert_eq!(cache.len(), 2);
236    }
237
238    #[test]
239    fn test_cache_new_is_empty() {
240        let cache = SchemaCache::new();
241        assert!(cache.is_empty());
242        assert_eq!(cache.len(), 0);
243    }
244
245    #[test]
246    fn test_cache_default_is_empty() {
247        let cache = SchemaCache::default();
248        assert!(cache.is_empty());
249    }
250
251    #[test]
252    fn test_cache_handles_empty_schema() {
253        let cache = SchemaCache::new();
254        let adapter = GenericSchemaAdapter;
255        let schema = json!({});
256
257        let result = cache.get_or_normalize(&schema, &adapter);
258        assert_eq!(result, json!({}));
259        assert_eq!(cache.len(), 1);
260    }
261
262    #[test]
263    fn test_cache_handles_null_schema() {
264        let cache = SchemaCache::new();
265        let adapter = GenericSchemaAdapter;
266        let schema = Value::Null;
267
268        let result = cache.get_or_normalize(&schema, &adapter);
269        // GenericSchemaAdapter passes through non-object values
270        assert_eq!(result, Value::Null);
271        assert_eq!(cache.len(), 1);
272    }
273}