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
56impl<T: DeserializeOwned + JsonSchema + Send + Sync + 'static> TypedTool<T> {
57    /// Create a new typed function tool with auto-generated schema.
58    ///
59    /// The JSON Schema is derived from `T`'s [`JsonSchema`] implementation,
60    /// including any doc-comment descriptions on fields.
61    pub fn new<F, Fut>(name: impl Into<String>, description: impl Into<String>, handler: F) -> Self
62    where
63        F: Fn(T) -> Fut + Send + Sync + 'static,
64        Fut: std::future::Future<Output = Result<serde_json::Value, ToolError>> + Send + 'static,
65    {
66        let schema = super::wire_schema::<T>();
67
68        Self {
69            name: name.into(),
70            description: description.into(),
71            schema,
72            handler: Box::new(move |args| Box::pin(handler(args))),
73            _phantom: PhantomData,
74        }
75    }
76}
77
78#[async_trait]
79impl<T: DeserializeOwned + JsonSchema + Send + Sync + 'static> ToolFunction for TypedTool<T> {
80    fn name(&self) -> &str {
81        &self.name
82    }
83
84    fn description(&self) -> &str {
85        &self.description
86    }
87
88    fn parameters(&self) -> Option<serde_json::Value> {
89        Some(self.schema.clone())
90    }
91
92    async fn call(&self, args: serde_json::Value) -> Result<serde_json::Value, ToolError> {
93        let typed_args: T = serde_json::from_value(args)
94            .map_err(|e| ToolError::InvalidArgs(format!("Failed to deserialize arguments: {e}")))?;
95        (self.handler)(typed_args).await
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102    use serde::Deserialize;
103
104    /// A nested type is what triggers `definitions` + `$ref`.
105    #[derive(Deserialize, JsonSchema)]
106    #[allow(dead_code)]
107    enum Scope {
108        Recent,
109        Persistent,
110    }
111
112    #[derive(Deserialize, JsonSchema)]
113    #[allow(dead_code)]
114    struct Args {
115        /// What to look for.
116        query: String,
117        /// Which slice to search.
118        scope: Scope,
119        /// Optional — the construct the API rejects outright.
120        note: Option<String>,
121    }
122
123    fn schema_of<T: DeserializeOwned + JsonSchema + Send + Sync + 'static>() -> serde_json::Value {
124        TypedTool::<T>::new("probe", "Probe", |_: T| async { Ok(serde_json::json!({})) })
125            .parameters()
126            .expect("typed tools declare parameters")
127    }
128
129    /// A declaration that points outside itself is not merely useless — the
130    /// Live endpoint closes the connection during setup rather than accept it,
131    /// and the batch endpoints ignore the constraint and let the model invent
132    /// enum variants that will not deserialize.
133    #[test]
134    fn a_nested_type_does_not_leak_refs_into_the_declaration() {
135        let rendered = schema_of::<Args>().to_string();
136
137        assert!(
138            !rendered.contains("$ref"),
139            "the API does not resolve $ref, so the schema silently stops \
140             constraining: {rendered}"
141        );
142        assert!(
143            !rendered.contains("definitions"),
144            "schema leaks definitions: {rendered}"
145        );
146        assert!(
147            !rendered.contains("$schema"),
148            "schema leaks its meta-schema: {rendered}"
149        );
150    }
151
152    /// Inlining must preserve the constraint, not just remove the pointer.
153    #[test]
154    fn the_inlined_schema_still_carries_the_enum_variants() {
155        let schema = schema_of::<Args>();
156        let scope = &schema["properties"]["scope"];
157
158        assert_eq!(
159            scope["type"], "string",
160            "a fieldless enum must narrow to a plain string type: {scope}"
161        );
162        assert_eq!(
163            scope["enum"],
164            serde_json::json!(["Recent", "Persistent"]),
165            "flattening dropped the variants it was supposed to preserve: {scope}"
166        );
167        assert!(
168            scope.get("oneOf").is_none(),
169            "the API does not understand `oneOf` here, so the constraint would \
170             silently stop applying: {scope}"
171        );
172    }
173
174    /// The one that closes a Live session mid-handshake: `Option<String>`
175    /// derives `"type": ["string", "null"]`, and the API's `Schema.type` is a
176    /// single value. It rejects the whole request rather than ignoring it.
177    #[test]
178    fn an_optional_field_does_not_declare_a_union_type() {
179        let schema = schema_of::<Args>();
180        let note = &schema["properties"]["note"];
181
182        assert_eq!(
183            note["type"], "string",
184            "a union type is rejected outright by the API: {note}"
185        );
186        assert!(
187            !schema["required"]
188                .as_array()
189                .expect("required list")
190                .iter()
191                .any(|r| r == "note"),
192            "optionality must still be carried by absence from `required`: {schema}"
193        );
194    }
195}