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    /// The function-declaration field this adapter's output must be posted under.
148    ///
149    /// A provider may accept more than one schema dialect on different fields —
150    /// Gemini takes an OpenAPI subset on `parameters` and standard JSON Schema
151    /// on `parametersJsonSchema`, and the two are mutually exclusive. The
152    /// reduction and the field name are therefore **one decision**: a schema
153    /// reduced for one dialect but posted under the other's field is either
154    /// rejected outright, or silently accepted carrying constraints the model
155    /// was never shown.
156    ///
157    /// Returning the field from the adapter keeps that decision in the one place
158    /// that already knows which dialect it produced, so a caller cannot pick the
159    /// wrong one. Defaults to `"parameters"`, which is correct for every
160    /// provider having only one such field.
161    ///
162    /// # Example
163    ///
164    /// ```rust
165    /// use adk_core::SchemaAdapter;
166    /// use serde_json::Value;
167    ///
168    /// #[derive(Debug)]
169    /// struct TestAdapter;
170    /// impl SchemaAdapter for TestAdapter {
171    ///     fn normalize_schema(&self, schema: Value) -> Value { schema }
172    /// }
173    ///
174    /// assert_eq!(TestAdapter.parameters_field(), "parameters");
175    /// ```
176    fn parameters_field(&self) -> &'static str {
177        "parameters"
178    }
179}
180
181/// Default schema adapter for providers with no specific requirements (Ollama, etc.).
182///
183/// Applies a conservative set of shared utility transforms:
184/// 1. Strip `$schema` keyword
185/// 2. Strip conditional keywords (`if`/`then`/`else`)
186/// 3. Convert `const` to single-element `enum`
187/// 4. Add implicit `"type": "object"` when `properties` exists
188/// 5. Strip unsupported `format` values
189///
190/// This adapter does not resolve `$ref`, collapse combiners, or enforce nesting
191/// depth limits. It is suitable for providers that accept most JSON Schema features
192/// but reject the meta-keywords and conditional constructs.
193///
194/// Used as the default return value of [`Llm::schema_adapter()`](crate::Llm::schema_adapter)
195/// for providers that do not override it.
196///
197/// # Example
198///
199/// ```rust
200/// use adk_core::{GenericSchemaAdapter, SchemaAdapter};
201/// use serde_json::json;
202///
203/// let adapter = GenericSchemaAdapter;
204/// let schema = json!({
205///     "$schema": "http://json-schema.org/draft-07/schema#",
206///     "properties": {
207///         "name": { "type": "string", "const": "fixed" }
208///     },
209///     "if": { "properties": { "x": { "type": "number" } } },
210///     "then": { "required": ["x"] }
211/// });
212///
213/// let normalized = adapter.normalize_schema(schema);
214/// assert!(normalized.get("$schema").is_none());
215/// assert!(normalized.get("if").is_none());
216/// assert!(normalized.get("then").is_none());
217/// assert_eq!(normalized["type"], "object");
218/// assert_eq!(normalized["properties"]["name"]["enum"], json!(["fixed"]));
219/// ```
220#[derive(Debug)]
221pub struct GenericSchemaAdapter;
222
223/// Allowed format values for the generic adapter (same as Gemini).
224const GENERIC_ALLOWED_FORMATS: &[&str] =
225    &["date-time", "date", "time", "email", "uri", "uuid", "int32", "int64", "float", "double"];
226
227impl SchemaAdapter for GenericSchemaAdapter {
228    fn normalize_schema(&self, mut schema: Value) -> Value {
229        schema_utils::strip_schema_keyword(&mut schema);
230        schema_utils::strip_conditional_keywords(&mut schema);
231        schema_utils::convert_const_to_enum(&mut schema);
232        schema_utils::add_implicit_object_type(&mut schema);
233        schema_utils::strip_unsupported_formats(&mut schema, GENERIC_ALLOWED_FORMATS);
234        schema
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use serde_json::json;
242
243    #[test]
244    fn test_generic_adapter_strips_schema_keyword() {
245        let adapter = GenericSchemaAdapter;
246        let schema = json!({
247            "$schema": "http://json-schema.org/draft-07/schema#",
248            "type": "object",
249            "properties": { "name": { "type": "string" } }
250        });
251        let result = adapter.normalize_schema(schema);
252        assert!(result.get("$schema").is_none());
253        assert_eq!(result["type"], "object");
254    }
255
256    #[test]
257    fn test_generic_adapter_strips_conditional_keywords() {
258        let adapter = GenericSchemaAdapter;
259        let schema = json!({
260            "type": "object",
261            "if": { "properties": { "kind": { "const": "a" } } },
262            "then": { "required": ["extra"] },
263            "else": { "required": [] }
264        });
265        let result = adapter.normalize_schema(schema);
266        assert!(result.get("if").is_none());
267        assert!(result.get("then").is_none());
268        assert!(result.get("else").is_none());
269    }
270
271    #[test]
272    fn test_generic_adapter_converts_const_to_enum() {
273        let adapter = GenericSchemaAdapter;
274        let schema = json!({
275            "type": "string",
276            "const": "fixed_value"
277        });
278        let result = adapter.normalize_schema(schema);
279        assert!(result.get("const").is_none());
280        assert_eq!(result["enum"], json!(["fixed_value"]));
281    }
282
283    #[test]
284    fn test_generic_adapter_adds_implicit_object_type() {
285        let adapter = GenericSchemaAdapter;
286        let schema = json!({
287            "properties": {
288                "name": { "type": "string" }
289            }
290        });
291        let result = adapter.normalize_schema(schema);
292        assert_eq!(result["type"], "object");
293    }
294
295    #[test]
296    fn test_generic_adapter_strips_unsupported_formats() {
297        let adapter = GenericSchemaAdapter;
298        let schema = json!({
299            "type": "object",
300            "properties": {
301                "created": { "type": "string", "format": "date-time" },
302                "hostname": { "type": "string", "format": "hostname" },
303                "email": { "type": "string", "format": "email" }
304            }
305        });
306        let result = adapter.normalize_schema(schema);
307        assert_eq!(result["properties"]["created"]["format"], "date-time");
308        assert!(result["properties"]["hostname"].get("format").is_none());
309        assert_eq!(result["properties"]["email"]["format"], "email");
310    }
311
312    #[test]
313    fn test_generic_adapter_preserves_allowed_formats() {
314        let adapter = GenericSchemaAdapter;
315        for format in GENERIC_ALLOWED_FORMATS {
316            let schema = json!({ "type": "string", "format": format });
317            let result = adapter.normalize_schema(schema);
318            assert_eq!(result["format"], *format, "format '{format}' should be preserved");
319        }
320    }
321
322    #[test]
323    fn test_generic_adapter_all_transforms_combined() {
324        let adapter = GenericSchemaAdapter;
325        let schema = json!({
326            "$schema": "http://json-schema.org/draft-07/schema#",
327            "properties": {
328                "status": { "type": "string", "const": "active" },
329                "host": { "type": "string", "format": "hostname" },
330                "created": { "type": "string", "format": "date-time" }
331            },
332            "if": { "properties": { "status": { "const": "active" } } },
333            "then": { "required": ["host"] }
334        });
335        let result = adapter.normalize_schema(schema);
336
337        // $schema removed
338        assert!(result.get("$schema").is_none());
339        // conditional keywords removed
340        assert!(result.get("if").is_none());
341        assert!(result.get("then").is_none());
342        // implicit type added
343        assert_eq!(result["type"], "object");
344        // const converted to enum
345        assert!(result["properties"]["status"].get("const").is_none());
346        assert_eq!(result["properties"]["status"]["enum"], json!(["active"]));
347        // unsupported format stripped
348        assert!(result["properties"]["host"].get("format").is_none());
349        // allowed format preserved
350        assert_eq!(result["properties"]["created"]["format"], "date-time");
351    }
352
353    #[test]
354    fn test_generic_adapter_nested_transforms() {
355        let adapter = GenericSchemaAdapter;
356        let schema = json!({
357            "type": "object",
358            "properties": {
359                "nested": {
360                    "$schema": "draft-07",
361                    "properties": {
362                        "deep": {
363                            "type": "string",
364                            "const": "value",
365                            "format": "ipv4"
366                        }
367                    },
368                    "if": { "const": true },
369                    "then": { "type": "string" }
370                }
371            }
372        });
373        let result = adapter.normalize_schema(schema);
374        let nested = &result["properties"]["nested"];
375        assert!(nested.get("$schema").is_none());
376        assert!(nested.get("if").is_none());
377        assert!(nested.get("then").is_none());
378        assert_eq!(nested["type"], "object");
379        assert_eq!(nested["properties"]["deep"]["enum"], json!(["value"]));
380        assert!(nested["properties"]["deep"].get("format").is_none());
381    }
382
383    #[test]
384    fn test_generic_adapter_idempotent() {
385        let adapter = GenericSchemaAdapter;
386        let schema = json!({
387            "$schema": "http://json-schema.org/draft-07/schema#",
388            "properties": {
389                "name": { "type": "string", "const": "test", "format": "hostname" }
390            },
391            "if": { "const": true },
392            "then": { "required": ["name"] }
393        });
394        let first = adapter.normalize_schema(schema);
395        let second = adapter.normalize_schema(first.clone());
396        assert_eq!(first, second);
397    }
398
399    #[test]
400    fn test_generic_adapter_empty_schema_passthrough() {
401        let adapter = GenericSchemaAdapter;
402        let schema = json!({});
403        let result = adapter.normalize_schema(schema);
404        assert_eq!(result, json!({}));
405    }
406
407    #[test]
408    fn test_generic_adapter_preserves_refs_and_combiners() {
409        let adapter = GenericSchemaAdapter;
410        let schema = json!({
411            "type": "object",
412            "$ref": "#/definitions/Foo",
413            "anyOf": [{ "type": "string" }, { "type": "number" }],
414            "oneOf": [{ "type": "boolean" }],
415            "allOf": [{ "required": ["a"] }],
416            "additionalProperties": false
417        });
418        let result = adapter.normalize_schema(schema);
419        // GenericSchemaAdapter does NOT resolve refs or collapse combiners
420        assert!(result.get("$ref").is_some());
421        assert!(result.get("anyOf").is_some());
422        assert!(result.get("oneOf").is_some());
423        assert!(result.get("allOf").is_some());
424        assert!(result.get("additionalProperties").is_some());
425    }
426}