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}