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}