gemini_adk_rs/tool/
typed.rs1use 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
13pub 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 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 #[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 query: String,
117 scope: Scope,
119 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 #[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 #[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 #[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}