Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:55:18.761Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

tool_schema.rs

1241 lines · 37.7 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.