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}