Skip to main content

adk_core/
schema_adapter.rs

1//! Schema normalization adapter for LLM provider function-calling APIs.
2//!
3//! Each LLM provider has different JSON Schema requirements for tool parameters.
4//! The [`SchemaAdapter`] trait provides a consistent interface for transforming
5//! raw MCP tool schemas into the provider's accepted format at request time.
6//!
7//! # Architecture
8//!
9//! `McpToolset` returns raw schemas verbatim. Each model adapter implements
10//! `SchemaAdapter` to normalize schemas according to its backend's limitations.
11//! This separation keeps MCP tool discovery independent of LLM-specific concerns.
12//!
13//! # Example
14//!
15//! ```rust
16//! use adk_core::SchemaAdapter;
17//! use serde_json::{json, Value};
18//! use std::borrow::Cow;
19//!
20//! #[derive(Debug)]
21//! struct MyAdapter;
22//!
23//! impl SchemaAdapter for MyAdapter {
24//!     fn normalize_schema(&self, schema: Value) -> Value {
25//!         // Apply provider-specific transforms
26//!         schema
27//!     }
28//! }
29//!
30//! let adapter = MyAdapter;
31//! let raw = json!({"type": "object", "properties": {"name": {"type": "string"}}});
32//! let normalized = adapter.normalize_schema(raw);
33//! ```
34
35use serde_json::Value;
36use std::borrow::Cow;
37
38use crate::schema_utils;
39
40/// Normalizes JSON Schema for a specific LLM provider's function-calling API.
41///
42/// Each provider has different schema requirements. The adapter transforms
43/// raw MCP tool schemas into the provider's accepted format at request time.
44///
45/// # Default Implementations
46///
47/// - [`normalize_tool_name`](SchemaAdapter::normalize_tool_name): Truncates names
48///   exceeding 64 bytes at a valid UTF-8 character boundary.
49/// - [`empty_schema`](SchemaAdapter::empty_schema): Returns
50///   `{"type": "object", "properties": {}}` as the fallback when no input schema
51///   is provided.
52///
53/// # Thread Safety
54///
55/// All implementations must be `Send + Sync` to support concurrent request building
56/// across async tasks.
57pub trait SchemaAdapter: Send + Sync + std::fmt::Debug {
58    /// Normalize a raw JSON Schema for this provider.
59    ///
60    /// Called once per tool per request (results may be cached by the model adapter layer).
61    ///
62    /// # Arguments
63    ///
64    /// * `schema` - The raw JSON Schema value from an MCP tool's `inputSchema`.
65    ///
66    /// # Returns
67    ///
68    /// A normalized JSON Schema value accepted by this provider's API.
69    fn normalize_schema(&self, schema: Value) -> Value;
70
71    /// Normalize a tool name for this provider's limits.
72    ///
73    /// Default implementation truncates names exceeding 64 bytes at the nearest
74    /// valid UTF-8 character boundary, preserving the prefix.
75    ///
76    /// # Arguments
77    ///
78    /// * `name` - The original tool name.
79    ///
80    /// # Returns
81    ///
82    /// A [`Cow::Borrowed`] reference if the name fits within 64 bytes, or a
83    /// [`Cow::Owned`] truncated string otherwise.
84    ///
85    /// # Example
86    ///
87    /// ```rust
88    /// use adk_core::SchemaAdapter;
89    /// use serde_json::Value;
90    /// use std::borrow::Cow;
91    ///
92    /// #[derive(Debug)]
93    /// struct TestAdapter;
94    /// impl SchemaAdapter for TestAdapter {
95    ///     fn normalize_schema(&self, schema: Value) -> Value { schema }
96    /// }
97    ///
98    /// let adapter = TestAdapter;
99    ///
100    /// // Short names are returned as-is
101    /// assert_eq!(adapter.normalize_tool_name("get_weather"), Cow::Borrowed("get_weather"));
102    ///
103    /// // Long names are truncated to at most 64 bytes
104    /// let long_name = "a".repeat(100);
105    /// let result = adapter.normalize_tool_name(&long_name);
106    /// assert!(result.len() <= 64);
107    /// ```
108    fn normalize_tool_name<'a>(&self, name: &'a str) -> Cow<'a, str> {
109        if name.len() <= 64 {
110            Cow::Borrowed(name)
111        } else {
112            // Find the largest valid UTF-8 boundary at or before 64 bytes.
113            // Walk backward from byte 64 until we hit a byte that is not a
114            // UTF-8 continuation byte (0b10xxxxxx).
115            let mut end = 64;
116            while end > 0 && !name.is_char_boundary(end) {
117                end -= 1;
118            }
119            Cow::Owned(name[..end].to_string())
120        }
121    }
122
123    /// Fallback schema when a tool provides no `parameters_schema`.
124    ///
125    /// Returns `{"type": "object", "properties": {}}` by default, which represents
126    /// a tool that accepts no parameters.
127    ///
128    /// # Example
129    ///
130    /// ```rust
131    /// use adk_core::SchemaAdapter;
132    /// use serde_json::{json, Value};
133    ///
134    /// #[derive(Debug)]
135    /// struct TestAdapter;
136    /// impl SchemaAdapter for TestAdapter {
137    ///     fn normalize_schema(&self, schema: Value) -> Value { schema }
138    /// }
139    ///
140    /// let adapter = TestAdapter;
141    /// assert_eq!(adapter.empty_schema(), json!({"type": "object", "properties": {}}));
142    /// ```
143    fn empty_schema(&self) -> Value {
144        serde_json::json!({"type": "object", "properties": {}})
145    }
146}
147
148/// Default schema adapter for providers with no specific requirements (Ollama, etc.).
149///
150/// Applies a conservative set of shared utility transforms:
151/// 1. Strip `$schema` keyword
152/// 2. Strip conditional keywords (`if`/`then`/`else`)
153/// 3. Convert `const` to single-element `enum`
154/// 4. Add implicit `"type": "object"` when `properties` exists
155/// 5. Strip unsupported `format` values
156///
157/// This adapter does not resolve `$ref`, collapse combiners, or enforce nesting
158/// depth limits. It is suitable for providers that accept most JSON Schema features
159/// but reject the meta-keywords and conditional constructs.
160///
161/// Used as the default return value of [`Llm::schema_adapter()`](crate::Llm::schema_adapter)
162/// for providers that do not override it.
163///
164/// # Example
165///
166/// ```rust
167/// use adk_core::{GenericSchemaAdapter, SchemaAdapter};
168/// use serde_json::json;
169///
170/// let adapter = GenericSchemaAdapter;
171/// let schema = json!({
172///     "$schema": "http://json-schema.org/draft-07/schema#",
173///     "properties": {
174///         "name": { "type": "string", "const": "fixed" }
175///     },
176///     "if": { "properties": { "x": { "type": "number" } } },
177///     "then": { "required": ["x"] }
178/// });
179///
180/// let normalized = adapter.normalize_schema(schema);
181/// assert!(normalized.get("$schema").is_none());
182/// assert!(normalized.get("if").is_none());
183/// assert!(normalized.get("then").is_none());
184/// assert_eq!(normalized["type"], "object");
185/// assert_eq!(normalized["properties"]["name"]["enum"], json!(["fixed"]));
186/// ```
187#[derive(Debug)]
188pub struct GenericSchemaAdapter;
189
190/// Allowed format values for the generic adapter (same as Gemini).
191const GENERIC_ALLOWED_FORMATS: &[&str] =
192    &["date-time", "date", "time", "email", "uri", "uuid", "int32", "int64", "float", "double"];
193
194impl SchemaAdapter for GenericSchemaAdapter {
195    fn normalize_schema(&self, mut schema: Value) -> Value {
196        schema_utils::strip_schema_keyword(&mut schema);
197        schema_utils::strip_conditional_keywords(&mut schema);
198        schema_utils::convert_const_to_enum(&mut schema);
199        schema_utils::add_implicit_object_type(&mut schema);
200        schema_utils::strip_unsupported_formats(&mut schema, GENERIC_ALLOWED_FORMATS);
201        schema
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use serde_json::json;
209
210    #[test]
211    fn test_generic_adapter_strips_schema_keyword() {
212        let adapter = GenericSchemaAdapter;
213        let schema = json!({
214            "$schema": "http://json-schema.org/draft-07/schema#",
215            "type": "object",
216            "properties": { "name": { "type": "string" } }
217        });
218        let result = adapter.normalize_schema(schema);
219        assert!(result.get("$schema").is_none());
220        assert_eq!(result["type"], "object");
221    }
222
223    #[test]
224    fn test_generic_adapter_strips_conditional_keywords() {
225        let adapter = GenericSchemaAdapter;
226        let schema = json!({
227            "type": "object",
228            "if": { "properties": { "kind": { "const": "a" } } },
229            "then": { "required": ["extra"] },
230            "else": { "required": [] }
231        });
232        let result = adapter.normalize_schema(schema);
233        assert!(result.get("if").is_none());
234        assert!(result.get("then").is_none());
235        assert!(result.get("else").is_none());
236    }
237
238    #[test]
239    fn test_generic_adapter_converts_const_to_enum() {
240        let adapter = GenericSchemaAdapter;
241        let schema = json!({
242            "type": "string",
243            "const": "fixed_value"
244        });
245        let result = adapter.normalize_schema(schema);
246        assert!(result.get("const").is_none());
247        assert_eq!(result["enum"], json!(["fixed_value"]));
248    }
249
250    #[test]
251    fn test_generic_adapter_adds_implicit_object_type() {
252        let adapter = GenericSchemaAdapter;
253        let schema = json!({
254            "properties": {
255                "name": { "type": "string" }
256            }
257        });
258        let result = adapter.normalize_schema(schema);
259        assert_eq!(result["type"], "object");
260    }
261
262    #[test]
263    fn test_generic_adapter_strips_unsupported_formats() {
264        let adapter = GenericSchemaAdapter;
265        let schema = json!({
266            "type": "object",
267            "properties": {
268                "created": { "type": "string", "format": "date-time" },
269                "hostname": { "type": "string", "format": "hostname" },
270                "email": { "type": "string", "format": "email" }
271            }
272        });
273        let result = adapter.normalize_schema(schema);
274        assert_eq!(result["properties"]["created"]["format"], "date-time");
275        assert!(result["properties"]["hostname"].get("format").is_none());
276        assert_eq!(result["properties"]["email"]["format"], "email");
277    }
278
279    #[test]
280    fn test_generic_adapter_preserves_allowed_formats() {
281        let adapter = GenericSchemaAdapter;
282        for format in GENERIC_ALLOWED_FORMATS {
283            let schema = json!({ "type": "string", "format": format });
284            let result = adapter.normalize_schema(schema);
285            assert_eq!(result["format"], *format, "format '{format}' should be preserved");
286        }
287    }
288
289    #[test]
290    fn test_generic_adapter_all_transforms_combined() {
291        let adapter = GenericSchemaAdapter;
292        let schema = json!({
293            "$schema": "http://json-schema.org/draft-07/schema#",
294            "properties": {
295                "status": { "type": "string", "const": "active" },
296                "host": { "type": "string", "format": "hostname" },
297                "created": { "type": "string", "format": "date-time" }
298            },
299            "if": { "properties": { "status": { "const": "active" } } },
300            "then": { "required": ["host"] }
301        });
302        let result = adapter.normalize_schema(schema);
303
304        // $schema removed
305        assert!(result.get("$schema").is_none());
306        // conditional keywords removed
307        assert!(result.get("if").is_none());
308        assert!(result.get("then").is_none());
309        // implicit type added
310        assert_eq!(result["type"], "object");
311        // const converted to enum
312        assert!(result["properties"]["status"].get("const").is_none());
313        assert_eq!(result["properties"]["status"]["enum"], json!(["active"]));
314        // unsupported format stripped
315        assert!(result["properties"]["host"].get("format").is_none());
316        // allowed format preserved
317        assert_eq!(result["properties"]["created"]["format"], "date-time");
318    }
319
320    #[test]
321    fn test_generic_adapter_nested_transforms() {
322        let adapter = GenericSchemaAdapter;
323        let schema = json!({
324            "type": "object",
325            "properties": {
326                "nested": {
327                    "$schema": "draft-07",
328                    "properties": {
329                        "deep": {
330                            "type": "string",
331                            "const": "value",
332                            "format": "ipv4"
333                        }
334                    },
335                    "if": { "const": true },
336                    "then": { "type": "string" }
337                }
338            }
339        });
340        let result = adapter.normalize_schema(schema);
341        let nested = &result["properties"]["nested"];
342        assert!(nested.get("$schema").is_none());
343        assert!(nested.get("if").is_none());
344        assert!(nested.get("then").is_none());
345        assert_eq!(nested["type"], "object");
346        assert_eq!(nested["properties"]["deep"]["enum"], json!(["value"]));
347        assert!(nested["properties"]["deep"].get("format").is_none());
348    }
349
350    #[test]
351    fn test_generic_adapter_idempotent() {
352        let adapter = GenericSchemaAdapter;
353        let schema = json!({
354            "$schema": "http://json-schema.org/draft-07/schema#",
355            "properties": {
356                "name": { "type": "string", "const": "test", "format": "hostname" }
357            },
358            "if": { "const": true },
359            "then": { "required": ["name"] }
360        });
361        let first = adapter.normalize_schema(schema);
362        let second = adapter.normalize_schema(first.clone());
363        assert_eq!(first, second);
364    }
365
366    #[test]
367    fn test_generic_adapter_empty_schema_passthrough() {
368        let adapter = GenericSchemaAdapter;
369        let schema = json!({});
370        let result = adapter.normalize_schema(schema);
371        assert_eq!(result, json!({}));
372    }
373
374    #[test]
375    fn test_generic_adapter_preserves_refs_and_combiners() {
376        let adapter = GenericSchemaAdapter;
377        let schema = json!({
378            "type": "object",
379            "$ref": "#/definitions/Foo",
380            "anyOf": [{ "type": "string" }, { "type": "number" }],
381            "oneOf": [{ "type": "boolean" }],
382            "allOf": [{ "required": ["a"] }],
383            "additionalProperties": false
384        });
385        let result = adapter.normalize_schema(schema);
386        // GenericSchemaAdapter does NOT resolve refs or collapse combiners
387        assert!(result.get("$ref").is_some());
388        assert!(result.get("anyOf").is_some());
389        assert!(result.get("oneOf").is_some());
390        assert!(result.get("allOf").is_some());
391        assert!(result.get("additionalProperties").is_some());
392    }
393}