gemini_adk_rs/tool/typed.rs
1//! Type-safe function tool with auto-generated JSON Schema.
2
3use std::marker::PhantomData;
4
5use async_trait::async_trait;
6use schemars::JsonSchema;
7use serde::de::DeserializeOwned;
8
9use crate::error::ToolError;
10
11use super::ToolFunction;
12
13/// Type-safe function tool with auto-generated JSON Schema.
14///
15/// Unlike [`super::SimpleTool`] which takes raw `serde_json::Value` arguments and
16/// requires a manually written schema, `TypedTool` auto-generates the JSON
17/// Schema from a struct that derives [`schemars::JsonSchema`] and deserializes
18/// the arguments into that struct before calling the handler.
19///
20/// # Example
21///
22/// ```ignore
23/// use schemars::JsonSchema;
24/// use serde::Deserialize;
25///
26/// #[derive(Deserialize, JsonSchema)]
27/// struct WeatherArgs {
28/// /// The city to get weather for
29/// city: String,
30/// }
31///
32/// let tool = TypedTool::new::<WeatherArgs>(
33/// "get_weather",
34/// "Get current weather for a city",
35/// |args: WeatherArgs| async move {
36/// Ok(serde_json::json!({ "temp": 22, "city": args.city }))
37/// },
38/// );
39/// ```
40pub struct TypedTool<T: DeserializeOwned + JsonSchema + Send + Sync + 'static> {
41 name: String,
42 description: String,
43 schema: serde_json::Value,
44 #[allow(clippy::type_complexity)]
45 handler: Box<
46 dyn Fn(
47 T,
48 ) -> std::pin::Pin<
49 Box<dyn std::future::Future<Output = Result<serde_json::Value, ToolError>> + Send>,
50 > + Send
51 + Sync,
52 >,
53 _phantom: PhantomData<T>,
54}
55
56/// Derive a JSON Schema in the shape the Gemini API will actually enforce.
57///
58/// `schemars::schema_for!` hoists every nested type into `definitions` and
59/// points at it with `$ref`. The API does not resolve those references — it
60/// ignores them, silently. A declaration carrying `$ref` therefore degrades to
61/// "send some JSON": enum constraints stop applying and the model invents
62/// variants the type cannot deserialize. On the Live endpoint the failure is
63/// harsher still — the server closes the connection during setup rather than
64/// accepting the declaration.
65///
66/// So subschemas are inlined and `$schema`/`definitions` are stripped, leaving
67/// nothing that points outside the document. A schema that is ignored is worse
68/// than one that is absent: it reads as a constraint and behaves like free-form
69/// generation.
70///
71/// The result is then narrowed to the API's schema subset by
72/// [`narrow_to_api_subset`], which draft-07 is broader than in two ways that
73/// matter.
74fn wire_schema_for<T: JsonSchema>() -> serde_json::Value {
75 let settings = schemars::gen::SchemaSettings::draft07().with(|s| {
76 s.inline_subschemas = true;
77 s.meta_schema = None;
78 });
79 let root = settings.into_generator().into_root_schema_for::<T>();
80 let mut value = serde_json::to_value(root).expect("schemars schema should serialize to JSON");
81 if let Some(object) = value.as_object_mut() {
82 object.remove("$schema");
83 object.remove("definitions");
84 }
85 narrow_to_api_subset(&mut value);
86 value
87}
88
89/// Rewrite draft-07 constructs the Gemini schema subset cannot express.
90///
91/// Two rewrites, both verified against the live endpoint:
92///
93/// 1. **Nullable unions are collapsed.** `Option<String>` derives
94/// `"type": ["string", "null"]`, but the API's `Schema.type` is a single
95/// enum value, not a list. It does not ignore the list — it rejects the
96/// whole request (`Unknown name "type"`), which on the Live endpoint means
97/// the server closes the connection mid-handshake and the session never
98/// comes up. Optionality is already carried by absence from `required`, so
99/// dropping `"null"` loses nothing.
100///
101/// 2. **`oneOf` over single-variant enums is flattened** into one `type:
102/// string` with the variants in `enum`. That is how a fieldless Rust enum
103/// derives, and while the API tolerates the `oneOf` form it does not
104/// understand it — so the variant constraint quietly stops applying and the
105/// model is free to invent values that will not deserialize. Flattening
106/// restores the constraint the type already declared.
107fn narrow_to_api_subset(value: &mut serde_json::Value) {
108 match value {
109 serde_json::Value::Object(object) => {
110 // 1. `"type": [.., "null"]` → the single non-null member.
111 if let Some(serde_json::Value::Array(members)) = object.get("type") {
112 let mut kept = members
113 .iter()
114 .filter(|m| m.as_str() != Some("null"))
115 .cloned();
116 if let (Some(only), None) = (kept.next(), kept.next()) {
117 object.insert("type".into(), only);
118 }
119 }
120
121 // 2. `oneOf: [{enum: [a]}, {enum: [b]}]` → `type: string, enum: [a, b]`.
122 let flattened = object.get("oneOf").and_then(|one_of| {
123 let branches = one_of.as_array()?;
124 if branches.is_empty() {
125 return None;
126 }
127 branches
128 .iter()
129 .map(|branch| {
130 let single = branch.get("enum")?.as_array()?;
131 match single.as_slice() {
132 [only] if only.is_string() => Some(only.clone()),
133 _ => None,
134 }
135 })
136 .collect::<Option<Vec<_>>>()
137 });
138 if let Some(variants) = flattened {
139 object.remove("oneOf");
140 object.insert("type".into(), serde_json::Value::String("string".into()));
141 object.insert("enum".into(), serde_json::Value::Array(variants));
142 }
143
144 for nested in object.values_mut() {
145 narrow_to_api_subset(nested);
146 }
147 }
148 serde_json::Value::Array(items) => {
149 for item in items {
150 narrow_to_api_subset(item);
151 }
152 }
153 _ => {}
154 }
155}
156
157impl<T: DeserializeOwned + JsonSchema + Send + Sync + 'static> TypedTool<T> {
158 /// Create a new typed function tool with auto-generated schema.
159 ///
160 /// The JSON Schema is derived from `T`'s [`JsonSchema`] implementation,
161 /// including any doc-comment descriptions on fields.
162 pub fn new<F, Fut>(name: impl Into<String>, description: impl Into<String>, handler: F) -> Self
163 where
164 F: Fn(T) -> Fut + Send + Sync + 'static,
165 Fut: std::future::Future<Output = Result<serde_json::Value, ToolError>> + Send + 'static,
166 {
167 let schema = wire_schema_for::<T>();
168
169 Self {
170 name: name.into(),
171 description: description.into(),
172 schema,
173 handler: Box::new(move |args| Box::pin(handler(args))),
174 _phantom: PhantomData,
175 }
176 }
177}
178
179#[async_trait]
180impl<T: DeserializeOwned + JsonSchema + Send + Sync + 'static> ToolFunction for TypedTool<T> {
181 fn name(&self) -> &str {
182 &self.name
183 }
184
185 fn description(&self) -> &str {
186 &self.description
187 }
188
189 fn parameters(&self) -> Option<serde_json::Value> {
190 Some(self.schema.clone())
191 }
192
193 async fn call(&self, args: serde_json::Value) -> Result<serde_json::Value, ToolError> {
194 let typed_args: T = serde_json::from_value(args)
195 .map_err(|e| ToolError::InvalidArgs(format!("Failed to deserialize arguments: {e}")))?;
196 (self.handler)(typed_args).await
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use serde::Deserialize;
204
205 /// A nested type is what triggers `definitions` + `$ref`.
206 #[derive(Deserialize, JsonSchema)]
207 #[allow(dead_code)]
208 enum Scope {
209 Recent,
210 Persistent,
211 }
212
213 #[derive(Deserialize, JsonSchema)]
214 #[allow(dead_code)]
215 struct Args {
216 /// What to look for.
217 query: String,
218 /// Which slice to search.
219 scope: Scope,
220 /// Optional — the construct the API rejects outright.
221 note: Option<String>,
222 }
223
224 fn schema_of<T: DeserializeOwned + JsonSchema + Send + Sync + 'static>() -> serde_json::Value {
225 TypedTool::<T>::new("probe", "Probe", |_: T| async { Ok(serde_json::json!({})) })
226 .parameters()
227 .expect("typed tools declare parameters")
228 }
229
230 /// A declaration that points outside itself is not merely useless — the
231 /// Live endpoint closes the connection during setup rather than accept it,
232 /// and the batch endpoints ignore the constraint and let the model invent
233 /// enum variants that will not deserialize.
234 #[test]
235 fn a_nested_type_does_not_leak_refs_into_the_declaration() {
236 let rendered = schema_of::<Args>().to_string();
237
238 assert!(
239 !rendered.contains("$ref"),
240 "the API does not resolve $ref, so the schema silently stops \
241 constraining: {rendered}"
242 );
243 assert!(
244 !rendered.contains("definitions"),
245 "schema leaks definitions: {rendered}"
246 );
247 assert!(
248 !rendered.contains("$schema"),
249 "schema leaks its meta-schema: {rendered}"
250 );
251 }
252
253 /// Inlining must preserve the constraint, not just remove the pointer.
254 #[test]
255 fn the_inlined_schema_still_carries_the_enum_variants() {
256 let schema = schema_of::<Args>();
257 let scope = &schema["properties"]["scope"];
258
259 assert_eq!(
260 scope["type"], "string",
261 "a fieldless enum must narrow to a plain string type: {scope}"
262 );
263 assert_eq!(
264 scope["enum"],
265 serde_json::json!(["Recent", "Persistent"]),
266 "flattening dropped the variants it was supposed to preserve: {scope}"
267 );
268 assert!(
269 scope.get("oneOf").is_none(),
270 "the API does not understand `oneOf` here, so the constraint would \
271 silently stop applying: {scope}"
272 );
273 }
274
275 /// The one that closes a Live session mid-handshake: `Option<String>`
276 /// derives `"type": ["string", "null"]`, and the API's `Schema.type` is a
277 /// single value. It rejects the whole request rather than ignoring it.
278 #[test]
279 fn an_optional_field_does_not_declare_a_union_type() {
280 let schema = schema_of::<Args>();
281 let note = &schema["properties"]["note"];
282
283 assert_eq!(
284 note["type"], "string",
285 "a union type is rejected outright by the API: {note}"
286 );
287 assert!(
288 !schema["required"]
289 .as_array()
290 .expect("required list")
291 .iter()
292 .any(|r| r == "note"),
293 "optionality must still be carried by absence from `required`: {schema}"
294 );
295 }
296}