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}