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}