gemini_adk_rs/tool/
schema.rs

1//! One schema pipeline from Rust types to the Gemini wire format.
2
3use schemars::JsonSchema;
4
5/// Derive a JSON Schema in the shape the Gemini API will actually enforce.
6///
7/// This is the one way a Rust type becomes a schema on the wire: `#[tool]`
8/// parameters, [`TypedTool`](super::TypedTool) arguments, typed agent output
9/// and turn extraction all go through it. Derive `JsonSchema` on the type
10/// (doc comments on fields become descriptions) and call this instead of
11/// `schemars::schema_for!`, whose output the API misreads in the ways below.
12///
13/// ```
14/// use gemini_adk_rs::tool::wire_schema;
15///
16/// #[derive(schemars::JsonSchema)]
17/// #[allow(dead_code)]
18/// struct Lookup {
19///     /// The city to look up.
20///     city: String,
21///     units: Option<String>,
22/// }
23///
24/// let schema = wire_schema::<Lookup>();
25/// assert_eq!(schema["properties"]["city"]["description"], "The city to look up.");
26/// assert_eq!(schema["properties"]["units"]["type"], "string"); // not ["string", "null"]
27/// assert_eq!(schema["required"], serde_json::json!(["city"]));
28/// ```
29///
30/// `schemars::schema_for!` hoists every nested type into `definitions` and
31/// points at it with `$ref`. The API does not resolve those references — it
32/// ignores them, silently. A declaration carrying `$ref` therefore degrades to
33/// "send some JSON": enum constraints stop applying and the model invents
34/// variants the type cannot deserialize. On the Live endpoint the failure is
35/// harsher still — the server closes the connection during setup rather than
36/// accepting the declaration.
37///
38/// So subschemas are inlined and `$schema`/`definitions` are stripped, leaving
39/// nothing that points outside the document. A schema that is ignored is worse
40/// than one that is absent: it reads as a constraint and behaves like free-form
41/// generation.
42///
43/// The result is then narrowed to the API's schema subset, which draft-07 is
44/// broader than in ways that matter: a nullable union collapses to its one
45/// type, and a `oneOf` over single-variant enums flattens into one `enum`.
46pub fn wire_schema<T: JsonSchema + ?Sized>() -> serde_json::Value {
47    let settings = schemars::r#gen::SchemaSettings::draft07().with(|s| {
48        s.inline_subschemas = true;
49        s.meta_schema = None;
50    });
51    let root = settings.into_generator().into_root_schema_for::<T>();
52    let mut value = serde_json::to_value(root).expect("schemars schema should serialize to JSON");
53    if let Some(object) = value.as_object_mut() {
54        object.remove("$schema");
55        object.remove("definitions");
56    }
57    narrow_to_api_subset(&mut value);
58    value
59}
60
61/// Rewrite draft-07 constructs the Gemini schema subset cannot express.
62///
63/// Three rewrites:
64///
65/// 1. **Nullable unions are collapsed.** `Option<String>` derives
66///    `"type": ["string", "null"]`, but the API's `Schema.type` is a single
67///    enum value, not a list. It does not ignore the list — it rejects the
68///    whole request (`Unknown name "type"`), which on the Live endpoint means
69///    the server closes the connection mid-handshake and the session never
70///    comes up. Optionality is already carried by absence from `required`, so
71///    dropping `"null"` loses nothing.
72///
73/// 2. **`oneOf` over single-variant enums is flattened** into one `type:
74///    string` with the variants in `enum`. That is how a fieldless Rust enum
75///    derives, and while the API tolerates the `oneOf` form it does not
76///    understand it — so the variant constraint quietly stops applying and the
77///    model is free to invent values that will not deserialize. Flattening
78///    restores the constraint the type already declared.
79///
80/// 3. **A nullable composite collapses to the composite.** `Option<T>` for a
81///    struct or enum `T` derives `anyOf: [T, {"type": "null"}]`. As in 1,
82///    optionality is carried by `required`, so the `null` branch is dropped
83///    and the declaration is `T` itself, keeping the field's own
84///    `description`.
85fn narrow_to_api_subset(value: &mut serde_json::Value) {
86    match value {
87        serde_json::Value::Object(object) => {
88            // 1. `"type": [.., "null"]` → the single non-null member.
89            if let Some(serde_json::Value::Array(members)) = object.get("type") {
90                let mut kept = members
91                    .iter()
92                    .filter(|m| m.as_str() != Some("null"))
93                    .cloned();
94                if let (Some(only), None) = (kept.next(), kept.next()) {
95                    object.insert("type".into(), only);
96                }
97            }
98
99            // 3. `anyOf: [T, {type: null}]` → `T`, with the outer keys kept.
100            let collapsed = object.get("anyOf").and_then(|any_of| {
101                let mut kept = any_of
102                    .as_array()?
103                    .iter()
104                    .filter(|b| b.get("type").and_then(|t| t.as_str()) != Some("null"));
105                match (kept.next(), kept.next()) {
106                    (Some(serde_json::Value::Object(only)), None) => Some(only.clone()),
107                    _ => None,
108                }
109            });
110            if let Some(only) = collapsed {
111                object.remove("anyOf");
112                for (key, value) in only {
113                    object.entry(key).or_insert(value);
114                }
115            }
116
117            // 2. `oneOf: [{enum: [a]}, {enum: [b]}]` → `type: string, enum: [a, b]`.
118            let flattened = object.get("oneOf").and_then(|one_of| {
119                let branches = one_of.as_array()?;
120                if branches.is_empty() {
121                    return None;
122                }
123                branches
124                    .iter()
125                    .map(|branch| {
126                        let single = branch.get("enum")?.as_array()?;
127                        match single.as_slice() {
128                            [only] if only.is_string() => Some(only.clone()),
129                            _ => None,
130                        }
131                    })
132                    .collect::<Option<Vec<_>>>()
133            });
134            if let Some(variants) = flattened {
135                object.remove("oneOf");
136                object.insert("type".into(), serde_json::Value::String("string".into()));
137                object.insert("enum".into(), serde_json::Value::Array(variants));
138            }
139
140            for nested in object.values_mut() {
141                narrow_to_api_subset(nested);
142            }
143        }
144        serde_json::Value::Array(items) => {
145            for item in items {
146                narrow_to_api_subset(item);
147            }
148        }
149        _ => {}
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use serde_json::json;
157
158    #[derive(JsonSchema)]
159    #[allow(dead_code)]
160    enum Seating {
161        Indoor,
162        Outdoor,
163    }
164
165    /// A guest.
166    #[derive(JsonSchema)]
167    #[allow(dead_code)]
168    struct Guest {
169        name: String,
170    }
171
172    #[derive(JsonSchema)]
173    #[allow(dead_code)]
174    struct Booking {
175        /// Where to sit.
176        seating: Option<Seating>,
177        guest: Option<Guest>,
178    }
179
180    #[test]
181    fn an_optional_composite_declares_one_type() {
182        let schema = wire_schema::<Booking>();
183        let seating = &schema["properties"]["seating"];
184        assert!(seating.get("anyOf").is_none(), "{seating}");
185        assert_eq!(seating["type"], "string");
186        assert_eq!(seating["enum"], json!(["Indoor", "Outdoor"]));
187        assert_eq!(
188            seating["description"], "Where to sit.",
189            "the field's own description wins"
190        );
191
192        let guest = &schema["properties"]["guest"];
193        assert!(guest.get("anyOf").is_none(), "{guest}");
194        assert_eq!(guest["type"], "object");
195        assert_eq!(guest["required"], json!(["name"]));
196        assert!(
197            schema.get("required").is_none_or(|r| r == &json!([])),
198            "{schema}"
199        );
200    }
201}