Skip to repository content1241 lines · 37.7 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T01:51:22.235Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
tool_schema.rs
1use anyhow::Result;
2use schemars::{
3 JsonSchema, Schema,
4 generate::SchemaSettings,
5 transform::{Transform, transform_subschemas},
6};
7use serde_json::{Map, Value, json};
8
9/// Indicates the format used to define the input schema for a language model tool.
10#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
11pub enum LanguageModelToolSchemaFormat {
12 /// A JSON schema, see https://json-schema.org
13 JsonSchema,
14 /// A subset of an OpenAPI 3.0 schema object supported by Google AI, see https://ai.google.dev/api/caching#Schema
15 JsonSchemaSubset,
16}
17
18pub fn root_schema_for<T: JsonSchema>(format: LanguageModelToolSchemaFormat) -> Schema {
19 let mut generator = match format {
20 LanguageModelToolSchemaFormat::JsonSchema => SchemaSettings::draft07()
21 .with(|settings| {
22 settings.meta_schema = None;
23 settings.inline_subschemas = true;
24 })
25 .into_generator(),
26 LanguageModelToolSchemaFormat::JsonSchemaSubset => SchemaSettings::openapi3()
27 .with(|settings| {
28 settings.meta_schema = None;
29 settings.inline_subschemas = true;
30 })
31 .with_transform(ToJsonSchemaSubsetTransform)
32 .into_generator(),
33 };
34 generator.root_schema_for::<T>()
35}
36
37#[derive(Debug, Clone)]
38struct ToJsonSchemaSubsetTransform;
39
40impl Transform for ToJsonSchemaSubsetTransform {
41 fn transform(&mut self, schema: &mut Schema) {
42 if let Some(obj) = schema.as_object_mut() {
43 // `Option<T>` produces `type: [T, "null"]`. Convert to OpenAPI 3.0's
44 // `nullable: true` so nullability isn't silently dropped.
45 convert_null_in_types_to_nullable(obj);
46
47 // Any remaining multi-type array (uncommon in Rust-generated schemas)
48 // is collapsed to its first entry to keep this schema subset-compatible.
49 if let Some(type_field) = obj.get_mut("type")
50 && let Some(types) = type_field.as_array()
51 && let Some(first_type) = types.first().cloned()
52 {
53 *type_field = first_type;
54 }
55
56 // oneOf is not supported, use anyOf instead
57 if let Some(one_of) = obj.remove("oneOf") {
58 obj.insert("anyOf".to_string(), one_of);
59 }
60 }
61
62 transform_subschemas(self, schema);
63
64 if let Some(obj) = schema.as_object_mut() {
65 collapse_nullable_only_any_of(obj);
66 }
67 }
68}
69
70/// Tries to adapt a JSON schema representation to be compatible with the specified format.
71///
72/// If the json cannot be made compatible with the specified format, an error is returned.
73pub fn adapt_schema_to_format(
74 json: &mut Value,
75 format: LanguageModelToolSchemaFormat,
76) -> Result<()> {
77 log::trace!("Adapting schema to format {:?}: {}", format, json);
78
79 if let Value::Object(obj) = json {
80 obj.remove("$schema");
81 obj.remove("title");
82 obj.remove("description");
83 }
84
85 resolve_refs(json)?;
86
87 match format {
88 LanguageModelToolSchemaFormat::JsonSchema => preprocess_json_schema(json),
89 LanguageModelToolSchemaFormat::JsonSchemaSubset => adapt_to_json_schema_subset(json),
90 }?;
91
92 log::trace!("Adapted schema: {}", json);
93 Ok(())
94}
95
96fn preprocess_json_schema(json: &mut Value) -> Result<()> {
97 if let Value::Object(obj) = json
98 && matches!(obj.get("type"), Some(Value::String(s)) if s == "object")
99 {
100 if !obj.contains_key("additionalProperties") {
101 obj.insert("additionalProperties".to_string(), Value::Bool(false));
102 }
103
104 if !obj.contains_key("properties") {
105 obj.insert("properties".to_string(), Value::Object(Default::default()));
106 }
107 }
108 Ok(())
109}
110
111/// Inlines same-document `$ref`s from `$defs`/`definitions` and removes those.
112fn resolve_refs(json: &mut Value) -> Result<()> {
113 let Some(root_obj) = json.as_object_mut() else {
114 return Ok(());
115 };
116
117 let defs = root_obj.remove("$defs");
118 let legacy_defs = root_obj.remove("definitions");
119 if defs.is_none() && legacy_defs.is_none() {
120 return Ok(());
121 }
122
123 resolve_refs_recursive(json, defs.as_ref(), legacy_defs.as_ref(), &mut Vec::new())
124}
125
126fn resolve_refs_recursive(
127 value: &mut Value,
128 defs: Option<&Value>,
129 legacy_defs: Option<&Value>,
130 visiting: &mut Vec<String>,
131) -> Result<()> {
132 match value {
133 Value::Object(obj) => {
134 if let Some(ref_str) = obj.get("$ref").and_then(|v| v.as_str()) {
135 // Guard against cycles (A -> B -> A, or self-referential
136 // schemas like a Tree node whose children are Trees)
137 if visiting.iter().any(|v| v == ref_str) {
138 *obj = Map::new();
139 return Ok(());
140 }
141
142 let (defs_key, name) = parse_ref(ref_str)?;
143 let defs_for_key = match defs_key {
144 "$defs" => defs,
145 "definitions" => legacy_defs,
146 _ => None,
147 };
148 let Some(def) = defs_for_key.and_then(|defs| defs.get(name)) else {
149 anyhow::bail!("$ref target not found in {defs_key}: {ref_str}");
150 };
151
152 let ref_owned = ref_str.to_string();
153
154 // Inline the referenced definition into the current object.
155 let mut resolved = def.clone();
156 if let Value::Object(resolved_obj) = &mut resolved {
157 for (key, val) in obj.iter() {
158 if key != "$ref" {
159 resolved_obj.insert(key.clone(), val.clone());
160 }
161 }
162 }
163 *value = resolved;
164
165 visiting.push(ref_owned);
166 let result = resolve_refs_recursive(value, defs, legacy_defs, visiting);
167 visiting.pop();
168 return result;
169 }
170
171 let keys: Vec<String> = obj.keys().cloned().collect();
172 for key in keys {
173 if let Some(child) = obj.get_mut(&key) {
174 resolve_refs_recursive(child, defs, legacy_defs, visiting)?;
175 }
176 }
177 }
178 Value::Array(arr) => {
179 for item in arr.iter_mut() {
180 resolve_refs_recursive(item, defs, legacy_defs, visiting)?;
181 }
182 }
183 _ => {}
184 }
185 Ok(())
186}
187
188/// Parses a same-document `$ref` like `#/$defs/Foo` or `#/definitions/Foo`.
189/// Returns `(defs_key, name)` where `defs_key` is the top-level key the
190/// definition was looked up under, and `name` is the definition name.
191fn parse_ref(ref_str: &str) -> Result<(&'static str, &str)> {
192 if let Some(name) = ref_str.strip_prefix("#/$defs/") {
193 return Ok(("$defs", name));
194 }
195 if let Some(name) = ref_str.strip_prefix("#/definitions/") {
196 return Ok(("definitions", name));
197 }
198 anyhow::bail!(
199 "Unsupported $ref format (only `#/$defs/<name>` and `#/definitions/<name>` are supported): {ref_str}"
200 );
201}
202
203fn adapt_to_json_schema_subset(json: &mut Value) -> Result<()> {
204 if let Value::Object(obj) = json {
205 const UNSUPPORTED_KEYS: [&str; 4] = ["if", "then", "else", "$ref"];
206
207 for key in UNSUPPORTED_KEYS {
208 anyhow::ensure!(
209 !obj.contains_key(key),
210 "Schema cannot be made compatible because it contains \"{key}\""
211 );
212 }
213
214 const KEYS_TO_REMOVE: [(&str, fn(&Value) -> bool); 6] = [
215 ("format", |value| value.is_string()),
216 ("additionalProperties", |_| true),
217 ("propertyNames", |_| true),
218 ("exclusiveMinimum", |value| value.is_number()),
219 ("exclusiveMaximum", |value| value.is_number()),
220 ("optional", |value| value.is_boolean()),
221 ];
222 for (key, predicate) in KEYS_TO_REMOVE {
223 if let Some(value) = obj.get(key)
224 && predicate(value)
225 {
226 obj.remove(key);
227 }
228 }
229
230 convert_null_in_types_to_nullable(obj);
231 convert_types_to_any_of_defs(obj);
232
233 // After the conversions above, `type` should only ever still be an array
234 // if a malformed input had a single-element type array (e.g. `["string"]`).
235 // Collapse it to a single value so downstream consumers see a scalar.
236 if let Some(type_value) = obj.get_mut("type")
237 && let Some(types) = type_value.as_array()
238 && let Some(first_type) = types.first().cloned()
239 {
240 *type_value = first_type;
241 }
242
243 if matches!(obj.get("description"), Some(Value::String(_)))
244 && !obj.contains_key("type")
245 && !(obj.contains_key("anyOf")
246 || obj.contains_key("oneOf")
247 || obj.contains_key("allOf"))
248 {
249 obj.insert("type".to_string(), Value::String("string".to_string()));
250 }
251
252 if let Some(subschemas) = obj.get_mut("oneOf")
253 && subschemas.is_array()
254 {
255 let subschemas_clone = subschemas.clone();
256 obj.remove("oneOf");
257 push_any_of_constraint(obj, subschemas_clone);
258 }
259
260 for (_, value) in obj.iter_mut() {
261 if let Value::Object(_) | Value::Array(_) = value {
262 adapt_to_json_schema_subset(value)?;
263 }
264 }
265
266 // Children may have been rewritten from `{"type": "null"}` into
267 // `{"nullable": true}`. Fold those into the parent so the result matches
268 // OpenAPI 3.0's convention of `nullable: true` as a sibling of `type`.
269 collapse_nullable_only_any_of(obj);
270 } else if let Value::Array(arr) = json {
271 for item in arr.iter_mut() {
272 adapt_to_json_schema_subset(item)?;
273 }
274 }
275 Ok(())
276}
277
278fn convert_null_in_types_to_nullable(obj: &mut Map<String, Value>) {
279 let mut nullable_found_in_type = false;
280
281 if let Some(type_entry) = obj.get_mut("type") {
282 if let Some(types) = type_entry.as_array_mut() {
283 let mut had_null_type = false;
284 types.retain(|t| {
285 if t.as_str() == Some("null") {
286 had_null_type = true;
287 false
288 } else {
289 true
290 }
291 });
292
293 if had_null_type {
294 nullable_found_in_type = true;
295 if types.len() == 1 {
296 *type_entry = types.remove(0);
297 } else if types.is_empty() {
298 obj.remove("type");
299 }
300 }
301 } else if let Some(type_str) = type_entry.as_str() {
302 if type_str == "null" {
303 nullable_found_in_type = true;
304 obj.remove("type");
305 }
306 }
307 }
308 if nullable_found_in_type {
309 obj.insert("nullable".to_string(), Value::Bool(true));
310 }
311}
312
313fn convert_types_to_any_of_defs(obj: &mut Map<String, Value>) {
314 let is_multi_type = obj
315 .get("type")
316 .and_then(|v| v.as_array())
317 .is_some_and(|types| types.len() > 1);
318 if !is_multi_type {
319 return;
320 }
321
322 let Some(Value::Array(types)) = obj.remove("type") else {
323 return;
324 };
325 let any_of_schemas = types.into_iter().map(|t| json!({"type": t})).collect();
326 push_any_of_constraint(obj, Value::Array(any_of_schemas));
327}
328
329fn push_any_of_constraint(obj: &mut Map<String, Value>, any_of_schemas: Value) {
330 if let Some(existing_any_of) = obj.remove("anyOf") {
331 let mut all_of = match obj.remove("allOf") {
332 Some(Value::Array(arr)) => arr,
333 _ => Vec::new(),
334 };
335 // Always preserve the pre-existing `anyOf` — earlier this push was
336 // skipped when `allOf` was non-empty, which silently dropped it.
337 all_of.push(json!({"anyOf": existing_any_of}));
338 all_of.push(json!({"anyOf": any_of_schemas}));
339 obj.insert("allOf".to_string(), Value::Array(all_of));
340 } else if let Some(all_of) = obj.get_mut("allOf").and_then(|v| v.as_array_mut()) {
341 all_of.push(json!({"anyOf": any_of_schemas}));
342 } else {
343 obj.insert("anyOf".to_string(), any_of_schemas);
344 }
345}
346
347/// Folds `{nullable: true}`-only entries out of an `anyOf` array and onto the
348/// parent object. This matches OpenAPI 3.0 semantics, where nullability is
349/// expressed as a sibling of `type` rather than a separate variant.
350fn collapse_nullable_only_any_of(obj: &mut Map<String, Value>) {
351 let Some(Value::Array(mut any_of)) = obj.remove("anyOf") else {
352 return;
353 };
354
355 let mut found_nullable_only = false;
356 any_of.retain(|entry| {
357 let is_nullable_only = entry
358 .as_object()
359 .is_some_and(|m| m.len() == 1 && matches!(m.get("nullable"), Some(Value::Bool(true))));
360 if is_nullable_only {
361 found_nullable_only = true;
362 false
363 } else {
364 true
365 }
366 });
367
368 if !found_nullable_only {
369 obj.insert("anyOf".to_string(), Value::Array(any_of));
370 return;
371 }
372
373 obj.insert("nullable".to_string(), Value::Bool(true));
374
375 if any_of.is_empty() {
376 return;
377 }
378
379 // If a single variant remains and its keys don't collide with the parent's
380 // existing keys, inline it. `anyOf` with a single entry is equivalent to
381 // just that entry, and inlining produces the canonical OpenAPI form
382 // (e.g. `{type: "string", nullable: true}`).
383 if any_of.len() == 1
384 && let Value::Object(entry_obj) = &any_of[0]
385 && entry_obj.keys().all(|k| !obj.contains_key(k))
386 {
387 let entry = any_of.remove(0);
388 if let Value::Object(entry_obj) = entry {
389 for (k, v) in entry_obj {
390 obj.insert(k, v);
391 }
392 }
393 return;
394 }
395
396 obj.insert("anyOf".to_string(), Value::Array(any_of));
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use pretty_assertions::assert_eq;
403 use serde_json::json;
404
405 fn obj(value: Value) -> Map<String, Value> {
406 match value {
407 Value::Object(map) => map,
408 other => panic!("expected JSON object, got {other}"),
409 }
410 }
411
412 #[test]
413 fn test_convert_null_in_types_to_nullable() {
414 // ["string", "null"] -> "string", nullable: true
415 let mut o = obj(json!({"type": ["string", "null"]}));
416 convert_null_in_types_to_nullable(&mut o);
417 assert_eq!(o, obj(json!({"type": "string", "nullable": true})));
418
419 // "null" -> nullable: true
420 let mut o = obj(json!({"type": "null"}));
421 convert_null_in_types_to_nullable(&mut o);
422 assert_eq!(o, obj(json!({"nullable": true})));
423
424 // ["string", "number", "null"] -> ["string", "number"], nullable: true (anyOf handled elsewhere)
425 let mut o = obj(json!({"type": ["string", "number", "null"]}));
426 convert_null_in_types_to_nullable(&mut o);
427 assert_eq!(
428 o,
429 obj(json!({"type": ["string", "number"], "nullable": true}))
430 );
431
432 // "string" (no change, not nullable)
433 let mut o = obj(json!({"type": "string"}));
434 convert_null_in_types_to_nullable(&mut o);
435 assert_eq!(o, obj(json!({"type": "string"})));
436
437 // ["string", "number"] (no change, not nullable)
438 let mut o = obj(json!({"type": ["string", "number"]}));
439 convert_null_in_types_to_nullable(&mut o);
440 assert_eq!(o, obj(json!({"type": ["string", "number"]})));
441
442 // object with other properties, ["boolean", "null"]
443 let mut o = obj(json!({
444 "description": "A test field",
445 "type": ["boolean", "null"]
446 }));
447 convert_null_in_types_to_nullable(&mut o);
448 assert_eq!(
449 o,
450 obj(json!({
451 "description": "A test field",
452 "type": "boolean",
453 "nullable": true
454 }))
455 );
456 }
457
458 #[test]
459 fn test_convert_types_to_any_of_defs() {
460 // ["string", "number"] -> anyOf with string and number
461 let mut o = obj(json!({"type": ["string", "number"]}));
462 convert_types_to_any_of_defs(&mut o);
463 assert_eq!(
464 o,
465 obj(json!({
466 "anyOf": [
467 {"type": "string"},
468 {"type": "number"}
469 ]
470 }))
471 );
472
473 // "string" (no change)
474 let mut o = obj(json!({"type": "string"}));
475 convert_types_to_any_of_defs(&mut o);
476 assert_eq!(o, obj(json!({"type": "string"})));
477
478 // single-element array (no change, fallback in caller collapses it)
479 let mut o = obj(json!({"type": ["string"]}));
480 convert_types_to_any_of_defs(&mut o);
481 assert_eq!(o, obj(json!({"type": ["string"]})));
482
483 // object with other properties, ["string", "number"]
484 let mut o = obj(json!({
485 "description": "A test field",
486 "type": ["string", "number"]
487 }));
488 convert_types_to_any_of_defs(&mut o);
489 assert_eq!(
490 o,
491 obj(json!({
492 "description": "A test field",
493 "anyOf": [
494 {"type": "string"},
495 {"type": "number"}
496 ]
497 }))
498 );
499
500 // anyOf already present (no change)
501 let mut o = obj(json!({
502 "anyOf": [
503 {"type": "string"},
504 {"type": "number"}
505 ]
506 }));
507 convert_types_to_any_of_defs(&mut o);
508 assert_eq!(
509 o,
510 obj(json!({
511 "anyOf": [
512 {"type": "string"},
513 {"type": "number"}
514 ]
515 }))
516 );
517
518 // both type array and anyOf present
519 let mut o = obj(json!({
520 "type": ["string", "number"],
521 "anyOf": [
522 {"format": "email"}
523 ]
524 }));
525 convert_types_to_any_of_defs(&mut o);
526 assert_eq!(
527 o,
528 obj(json!({
529 "allOf": [
530 {
531 "anyOf": [
532 {"format": "email"}
533 ]
534 },
535 {
536 "anyOf": [
537 {"type": "string"},
538 {"type": "number"}
539 ]
540 }
541 ]
542 }))
543 );
544
545 // type array + anyOf + pre-existing allOf: pre-existing anyOf must not
546 // be silently dropped just because allOf is non-empty.
547 let mut o = obj(json!({
548 "type": ["string", "number"],
549 "anyOf": [{"minLength": 5}],
550 "allOf": [{"maxLength": 100}]
551 }));
552 convert_types_to_any_of_defs(&mut o);
553 assert_eq!(
554 o,
555 obj(json!({
556 "allOf": [
557 {"maxLength": 100},
558 {"anyOf": [{"minLength": 5}]},
559 {"anyOf": [{"type": "string"}, {"type": "number"}]}
560 ]
561 }))
562 );
563 }
564
565 #[test]
566 fn test_transform_adds_type_when_missing() {
567 let mut json = json!({
568 "description": "A test field without type"
569 });
570
571 adapt_to_json_schema_subset(&mut json).unwrap();
572
573 assert_eq!(
574 json,
575 json!({
576 "description": "A test field without type",
577 "type": "string"
578 })
579 );
580
581 let mut json = json!({
582 "description": {
583 "value": "abc",
584 "type": "string"
585 }
586 });
587
588 adapt_to_json_schema_subset(&mut json).unwrap();
589
590 assert_eq!(
591 json,
592 json!({
593 "description": {
594 "value": "abc",
595 "type": "string"
596 }
597 })
598 );
599 }
600
601 #[test]
602 fn test_transform_removes_unsupported_keys() {
603 let mut json = json!({
604 "description": "A test field",
605 "type": "integer",
606 "format": "uint32",
607 "exclusiveMinimum": 0,
608 "exclusiveMaximum": 100,
609 "additionalProperties": false,
610 "optional": true
611 });
612
613 adapt_to_json_schema_subset(&mut json).unwrap();
614
615 assert_eq!(
616 json,
617 json!({
618 "description": "A test field",
619 "type": "integer"
620 })
621 );
622
623 let mut json = json!({
624 "description": "A test field",
625 "type": "integer",
626 "format": {},
627 });
628
629 adapt_to_json_schema_subset(&mut json).unwrap();
630
631 assert_eq!(
632 json,
633 json!({
634 "description": "A test field",
635 "type": "integer",
636 "format": {},
637 })
638 );
639
640 let mut json = json!({
641 "type": "object",
642 "properties": {
643 "name": { "type": "string" }
644 },
645 "additionalProperties": { "type": "string" },
646 "propertyNames": { "pattern": "^[A-Za-z]+$" }
647 });
648
649 adapt_to_json_schema_subset(&mut json).unwrap();
650
651 assert_eq!(
652 json,
653 json!({
654 "type": "object",
655 "properties": {
656 "name": { "type": "string" }
657 }
658 })
659 );
660 }
661
662 #[test]
663 fn test_transform_null_in_any_of() {
664 let mut json = json!({
665 "anyOf": [
666 { "type": "string" },
667 { "type": "null" }
668 ]
669 });
670
671 adapt_to_json_schema_subset(&mut json).unwrap();
672
673 // Should collapse to the canonical OpenAPI 3.0 form: `nullable: true`
674 // as a sibling of `type`, rather than a separate anyOf variant.
675 assert_eq!(
676 json,
677 json!({
678 "type": "string",
679 "nullable": true
680 })
681 );
682 }
683
684 #[test]
685 fn test_transform_null_in_any_of_with_multiple_non_null_variants() {
686 let mut json = json!({
687 "anyOf": [
688 { "type": "string" },
689 { "type": "number" },
690 { "type": "null" }
691 ]
692 });
693
694 adapt_to_json_schema_subset(&mut json).unwrap();
695
696 // When more than one non-null variant remains, keep the anyOf and just
697 // hoist `nullable: true` onto the parent.
698 assert_eq!(
699 json,
700 json!({
701 "nullable": true,
702 "anyOf": [
703 { "type": "string" },
704 { "type": "number" }
705 ]
706 })
707 );
708 }
709
710 #[test]
711 fn test_transform_conflicting_any_of_sources() {
712 let mut json = json!({
713 "type": ["string", "number"],
714 "anyOf": [
715 { "minLength": 5 }
716 ],
717 "oneOf": [
718 { "pattern": "^a" },
719 { "pattern": "^b" }
720 ]
721 });
722
723 adapt_to_json_schema_subset(&mut json).unwrap();
724
725 assert_eq!(
726 json,
727 json!({
728 "allOf": [
729 {
730 "anyOf": [
731 { "minLength": 5 },
732 ]
733 },
734 {
735 "anyOf": [
736 {"type": "string"},
737 {"type": "number"}
738 ]
739 },
740 {
741 "anyOf": [
742 { "pattern": "^a" },
743 { "pattern": "^b" }
744 ]
745 }
746 ]
747 })
748 );
749 }
750
751 #[test]
752 fn test_transform_one_of_to_any_of() {
753 let mut json = json!({
754 "description": "A test field",
755 "oneOf": [
756 { "type": "string" },
757 { "type": "integer" }
758 ]
759 });
760
761 adapt_to_json_schema_subset(&mut json).unwrap();
762
763 assert_eq!(
764 json,
765 json!({
766 "description": "A test field",
767 "anyOf": [
768 { "type": "string" },
769 { "type": "integer" }
770 ]
771 })
772 );
773 }
774
775 #[test]
776 fn test_transform_nested_objects() {
777 let mut json = json!({
778 "type": "object",
779 "properties": {
780 "nested": {
781 "oneOf": [
782 { "type": "string" },
783 { "type": "null" }
784 ],
785 "format": "email"
786 }
787 }
788 });
789
790 adapt_to_json_schema_subset(&mut json).unwrap();
791
792 assert_eq!(
793 json,
794 json!({
795 "type": "object",
796 "properties": {
797 "nested": {
798 "type": "string",
799 "nullable": true
800 }
801 }
802 })
803 );
804 }
805
806 #[test]
807 fn test_transform_array_type_to_single_type() {
808 let mut json = json!({
809 "type": "object",
810 "properties": {
811 "projectSlugOrId": {
812 "type": ["string", "number"],
813 "description": "Project slug or numeric ID"
814 },
815 "optionalName": {
816 "type": ["string", "null"],
817 "description": "An optional name"
818 }
819 }
820 });
821
822 adapt_to_json_schema_subset(&mut json).unwrap();
823
824 assert_eq!(
825 json,
826 json!({
827 "type": "object",
828 "properties": {
829 "projectSlugOrId": {
830 "anyOf": [
831 {"type": "string"},
832 {"type": "number"}
833 ],
834 "description": "Project slug or numeric ID"
835 },
836 "optionalName": {
837 "type": "string",
838 "description": "An optional name",
839 "nullable": true
840 }
841 }
842 })
843 );
844 }
845
846 #[test]
847 fn test_transform_fails_if_unsupported_keys_exist() {
848 let mut json = json!({
849 "type": "object",
850 "properties": {
851 "$ref": "#/definitions/User",
852 }
853 });
854
855 assert!(adapt_to_json_schema_subset(&mut json).is_err());
856
857 let mut json = json!({
858 "type": "object",
859 "properties": {
860 "if": "...",
861 }
862 });
863
864 assert!(adapt_to_json_schema_subset(&mut json).is_err());
865
866 let mut json = json!({
867 "type": "object",
868 "properties": {
869 "then": "...",
870 }
871 });
872
873 assert!(adapt_to_json_schema_subset(&mut json).is_err());
874
875 let mut json = json!({
876 "type": "object",
877 "properties": {
878 "else": "...",
879 }
880 });
881
882 assert!(adapt_to_json_schema_subset(&mut json).is_err());
883 }
884
885 #[test]
886 fn test_refs_are_resolved_via_adapt_schema_to_format() {
887 let mut json = json!({
888 "type": "object",
889 "properties": {
890 "parent": {
891 "$ref": "#/$defs/pageParent"
892 },
893 "title": {
894 "type": "string",
895 "description": "Page title"
896 }
897 },
898 "required": ["parent"],
899 "$defs": {
900 "pageParent": {
901 "type": "object",
902 "properties": {
903 "type": {
904 "type": "string",
905 "description": "Parent type"
906 }
907 },
908 "required": ["type"]
909 }
910 }
911 });
912
913 adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset).unwrap();
914
915 let expected = json!({
916 "type": "object",
917 "properties": {
918 "parent": {
919 "type": "object",
920 "properties": {
921 "type": {
922 "type": "string",
923 "description": "Parent type"
924 }
925 },
926 "required": ["type"]
927 },
928 "title": {
929 "type": "string",
930 "description": "Page title"
931 }
932 },
933 "required": ["parent"],
934 });
935 assert_eq!(json, expected);
936 }
937
938 #[test]
939 fn test_refs_fail_for_unsupported_prefix() {
940 let mut json = json!({
941 "type": "object",
942 "properties": {
943 "child": {
944 "$ref": "https://example.com/schema.json#/User"
945 }
946 },
947 "$defs": {
948 "User": { "type": "string" }
949 }
950 });
951
952 assert!(
953 adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset)
954 .is_err()
955 );
956 }
957
958 #[test]
959 fn test_refs_fail_for_missing_definition() {
960 let mut json = json!({
961 "type": "object",
962 "properties": {
963 "child": {
964 "$ref": "#/$defs/NonExistent"
965 }
966 },
967 "$defs": {
968 "Existing": { "type": "string" }
969 }
970 });
971
972 assert!(
973 adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset)
974 .is_err()
975 );
976 }
977
978 #[test]
979 fn test_refs_in_defs_are_resolved() {
980 // A definition that itself references another definition.
981 let mut json = json!({
982 "type": "object",
983 "properties": {
984 "parent": {
985 "$ref": "#/$defs/pageParent"
986 }
987 },
988 "$defs": {
989 "pageParent": {
990 "type": "object",
991 "properties": {
992 "database_id": {
993 "$ref": "#/$defs/databaseId"
994 }
995 }
996 },
997 "databaseId": {
998 "type": "string",
999 "description": "A database ID"
1000 }
1001 }
1002 });
1003
1004 adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset).unwrap();
1005
1006 // The nested $ref in pageParent -> databaseId should be resolved.
1007 assert_eq!(
1008 json,
1009 json!({
1010 "type": "object",
1011 "properties": {
1012 "parent": {
1013 "type": "object",
1014 "properties": {
1015 "database_id": {
1016 "type": "string",
1017 "description": "A database ID"
1018 }
1019 }
1020 }
1021 }
1022 })
1023 );
1024 }
1025
1026 #[test]
1027 fn test_refs_resolve_when_both_defs_and_definitions_exist() {
1028 let mut json = json!({
1029 "type": "object",
1030 "properties": {
1031 "modern": {
1032 "$ref": "#/$defs/Modern"
1033 },
1034 "legacy": {
1035 "$ref": "#/definitions/Legacy"
1036 }
1037 },
1038 "$defs": {
1039 "Modern": {
1040 "type": "string"
1041 }
1042 },
1043 "definitions": {
1044 "Legacy": {
1045 "type": "number"
1046 }
1047 }
1048 });
1049
1050 adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset).unwrap();
1051
1052 assert_eq!(
1053 json,
1054 json!({
1055 "type": "object",
1056 "properties": {
1057 "modern": {
1058 "type": "string"
1059 },
1060 "legacy": {
1061 "type": "number"
1062 }
1063 }
1064 })
1065 );
1066 }
1067
1068 #[test]
1069 fn test_refs_in_array_items_are_resolved() {
1070 let mut json = json!({
1071 "type": "object",
1072 "properties": {
1073 "items": {
1074 "type": "array",
1075 "items": {
1076 "$ref": "#/$defs/itemDef"
1077 }
1078 }
1079 },
1080 "$defs": {
1081 "itemDef": {
1082 "type": "string",
1083 "description": "An item"
1084 }
1085 }
1086 });
1087
1088 adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset).unwrap();
1089
1090 assert_eq!(
1091 json,
1092 json!({
1093 "type": "object",
1094 "properties": {
1095 "items": {
1096 "type": "array",
1097 "items": {
1098 "type": "string",
1099 "description": "An item"
1100 }
1101 }
1102 }
1103 })
1104 );
1105 }
1106
1107 #[test]
1108 fn test_self_referential_ref_is_replaced_with_empty_schema() {
1109 // A common pattern: a Tree node with children of the same type.
1110 let mut json = json!({
1111 "type": "object",
1112 "properties": {
1113 "root": { "$ref": "#/$defs/Tree" }
1114 },
1115 "$defs": {
1116 "Tree": {
1117 "type": "object",
1118 "properties": {
1119 "value": { "type": "string" },
1120 "children": {
1121 "type": "array",
1122 "items": { "$ref": "#/$defs/Tree" }
1123 }
1124 }
1125 }
1126 }
1127 });
1128
1129 adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset)
1130 .expect("self-referential $ref should not error");
1131
1132 assert_eq!(
1133 json,
1134 json!({
1135 "type": "object",
1136 "properties": {
1137 "root": {
1138 "type": "object",
1139 "properties": {
1140 "value": { "type": "string" },
1141 "children": {
1142 "type": "array",
1143 "items": {}
1144 }
1145 }
1146 }
1147 }
1148 })
1149 );
1150 }
1151
1152 #[test]
1153 fn test_ref_sibling_properties_are_preserved() {
1154 // JSON Schema draft 2019-09+ allows sibling properties alongside
1155 // `$ref`. They must be merged into the resolved definition rather than
1156 // discarded, with siblings overriding the definition's keys.
1157 let mut json = json!({
1158 "type": "object",
1159 "properties": {
1160 "child": {
1161 "$ref": "#/$defs/childDef",
1162 "description": "Local description overrides def"
1163 }
1164 },
1165 "$defs": {
1166 "childDef": {
1167 "type": "string",
1168 "description": "Def description",
1169 "minLength": 1
1170 }
1171 }
1172 });
1173
1174 adapt_schema_to_format(&mut json, LanguageModelToolSchemaFormat::JsonSchemaSubset).unwrap();
1175
1176 assert_eq!(
1177 json["properties"]["child"],
1178 json!({
1179 "type": "string",
1180 "description": "Local description overrides def",
1181 "minLength": 1
1182 })
1183 );
1184 }
1185
1186 #[test]
1187 fn test_preprocess_json_schema_adds_additional_properties() {
1188 let mut json = json!({
1189 "type": "object",
1190 "properties": {
1191 "name": {
1192 "type": "string"
1193 }
1194 }
1195 });
1196
1197 preprocess_json_schema(&mut json).unwrap();
1198
1199 assert_eq!(
1200 json,
1201 json!({
1202 "type": "object",
1203 "properties": {
1204 "name": {
1205 "type": "string"
1206 }
1207 },
1208 "additionalProperties": false
1209 })
1210 );
1211 }
1212
1213 #[test]
1214 fn test_preprocess_json_schema_preserves_additional_properties() {
1215 let mut json = json!({
1216 "type": "object",
1217 "properties": {
1218 "name": {
1219 "type": "string"
1220 }
1221 },
1222 "additionalProperties": true
1223 });
1224
1225 preprocess_json_schema(&mut json).unwrap();
1226
1227 assert_eq!(
1228 json,
1229 json!({
1230 "type": "object",
1231 "properties": {
1232 "name": {
1233 "type": "string"
1234 }
1235 },
1236 "additionalProperties": true
1237 })
1238 );
1239 }
1240}
1241