1use gemini_genai_rs::prelude::FunctionDeclaration;
11use serde_json::{Map, Value};
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct Parameter {
16 pub name: String,
18 pub annotation: String,
20 pub required: bool,
22 pub description: Option<String>,
24}
25
26pub fn parameters(declaration: &FunctionDeclaration) -> Vec<Parameter> {
29 let Some(schema) = declaration.parameters.as_ref() else {
30 return Vec::new();
31 };
32 let properties = schema.get("properties").and_then(Value::as_object);
33 let required: Vec<&str> = schema
34 .get("required")
35 .and_then(Value::as_array)
36 .map(|r| r.iter().filter_map(Value::as_str).collect())
37 .unwrap_or_default();
38 let mut out: Vec<Parameter> = Vec::new();
39 let describe = |name: &str, required: bool| {
40 let property = properties.and_then(|p| p.get(name));
41 Parameter {
42 name: name.to_string(),
43 annotation: property.map_or_else(|| "object".to_string(), annotation),
44 required,
45 description: property
46 .and_then(|p| p.get("description"))
47 .and_then(Value::as_str)
48 .map(str::to_string)
49 .filter(|d| !d.trim().is_empty()),
50 }
51 };
52 for name in &required {
53 if !out.iter().any(|p| p.name == *name) {
54 out.push(describe(name, true));
55 }
56 }
57 if let Some(properties) = properties {
58 for name in properties.keys() {
59 if !required.contains(&name.as_str()) {
60 out.push(describe(name, false));
61 }
62 }
63 }
64 out
65}
66
67fn annotation(schema: &Value) -> String {
69 let kind = schema
70 .get("type")
71 .and_then(Value::as_str)
72 .map(str::to_ascii_lowercase);
73 let base = match kind.as_deref() {
74 Some("string") => "str".to_string(),
75 Some("integer") => "int".to_string(),
76 Some("number") => "float".to_string(),
77 Some("boolean") => "bool".to_string(),
78 Some("array") => match schema.get("items") {
79 Some(items) => format!("list[{}]", annotation(items)),
80 None => "list".to_string(),
81 },
82 Some("object") => "dict".to_string(),
83 _ => "object".to_string(),
84 };
85 let nullable = schema.get("nullable").and_then(Value::as_bool) == Some(true);
86 if nullable {
87 format!("{base} | None")
88 } else {
89 base
90 }
91}
92
93fn is_identifier(name: &str) -> bool {
94 let mut chars = name.chars();
95 chars
96 .next()
97 .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
98 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
99}
100
101pub fn python_stub(declaration: &FunctionDeclaration, returns: Option<&str>) -> String {
105 let params = parameters(declaration);
106 let signature = if params.iter().all(|p| is_identifier(&p.name)) {
107 params
108 .iter()
109 .map(|p| {
110 if p.required {
111 format!("{}: {}", p.name, p.annotation)
112 } else {
113 format!("{}: {} | None = None", p.name, p.annotation)
114 }
115 })
116 .collect::<Vec<_>>()
117 .join(", ")
118 } else {
119 "**kwargs".to_string()
121 };
122 let mut doc: Vec<String> = Vec::new();
123 let description = declaration.description.trim();
124 if !description.is_empty() {
125 doc.push(description.to_string());
126 }
127 for p in params.iter().filter(|p| p.description.is_some()) {
128 doc.push(format!(
129 "{}: {}",
130 p.name,
131 p.description.as_deref().unwrap_or_default()
132 ));
133 }
134 match returns.map(str::trim).filter(|r| !r.is_empty()) {
135 Some(returns) => doc.push(format!("Returns {returns}")),
136 None => doc.push("Returns the tool's JSON result.".to_string()),
137 }
138 format!(
139 "def {}({signature}) -> dict:\n \"\"\"{}\"\"\"",
140 declaration.name,
141 doc.join("\n ").replace("\"\"\"", "'''")
142 )
143}
144
145pub fn arguments(
148 declaration: &FunctionDeclaration,
149 positional: Vec<Value>,
150 keywords: Map<String, Value>,
151) -> Result<Value, String> {
152 let params = parameters(declaration);
153 let name = &declaration.name;
154 if positional.len() > params.len() {
155 return Err(format!(
156 "TypeError: {name}() takes {} positional argument{} but {} were given",
157 params.len(),
158 if params.len() == 1 { "" } else { "s" },
159 positional.len()
160 ));
161 }
162 let mut args = Map::new();
163 for (param, value) in params.iter().zip(positional) {
164 args.insert(param.name.clone(), value);
165 }
166 for (key, value) in keywords {
167 if args.contains_key(&key) {
168 return Err(format!(
169 "TypeError: {name}() got multiple values for argument '{key}'"
170 ));
171 }
172 args.insert(key, value);
173 }
174 args.retain(|key, value| {
177 !(value.is_null() && params.iter().any(|p| p.name == *key && !p.required))
178 });
179 let missing: Vec<&str> = params
180 .iter()
181 .filter(|p| p.required && !args.contains_key(&p.name))
182 .map(|p| p.name.as_str())
183 .collect();
184 if !missing.is_empty() {
185 return Err(format!(
186 "TypeError: {name}() missing required argument{}: {}",
187 if missing.len() == 1 { "" } else { "s" },
188 missing
189 .iter()
190 .map(|m| format!("'{m}'"))
191 .collect::<Vec<_>>()
192 .join(", ")
193 ));
194 }
195 Ok(Value::Object(args))
196}
197
198pub fn without_redefinitions(source: &str, functions: &[String]) -> Result<String, String> {
208 let lines: Vec<&str> = source.lines().collect();
209 let mut kept: Vec<&str> = Vec::with_capacity(lines.len());
210 let mut i = 0;
211 while i < lines.len() {
212 let line = lines[i];
213 let trimmed = line.trim_start();
214 let indent = line.len() - trimmed.len();
215 if let Some(name) = defined_name(trimmed).filter(|n| functions.iter().any(|f| f == n)) {
216 let Some((end, inline)) = signature_end(&lines, i) else {
218 kept.push(line);
219 i += 1;
220 continue;
221 };
222 let (body, next) = if strip_comment(inline).trim().is_empty() {
223 let mut next = end + 1;
224 while let Some(following) = lines.get(next) {
225 let following_indent = following.len() - following.trim_start().len();
226 if following.trim().is_empty() || following_indent > indent {
227 next += 1;
228 } else {
229 break;
230 }
231 }
232 (lines[end + 1..next].join("\n"), next)
233 } else {
234 (inline.to_string(), end + 1)
236 };
237 if !does_nothing(&body) {
238 return Err(redefined(name));
239 }
240 i = next;
241 continue;
242 }
243 if let Some(imported) = imported_names(trimmed)
244 && imported.iter().all(|n| functions.iter().any(|f| f == n))
245 {
246 i += 1;
247 continue;
248 }
249 if let Some(name) = assigned_name(trimmed).filter(|n| functions.iter().any(|f| f == n)) {
250 return Err(redefined(name));
251 }
252 kept.push(line);
253 i += 1;
254 }
255 Ok(kept.join("\n"))
256}
257
258fn signature_end<'a>(lines: &[&'a str], start: usize) -> Option<(usize, &'a str)> {
262 let mut depth = 0i32;
263 for (index, line) in lines.iter().enumerate().skip(start) {
264 let code = strip_comment(line);
265 for (at, c) in code.char_indices() {
266 match c {
267 '(' | '[' | '{' => depth += 1,
268 ')' | ']' | '}' => depth -= 1,
269 ':' if depth == 0 => return Some((index, &line[at + 1..])),
270 _ => {}
271 }
272 }
273 }
274 None
275}
276
277fn redefined(name: &str) -> String {
278 format!(
279 "The program defines '{name}', which is already defined: it calls the \
280 session's tool. Delete your definition and call {name}(...) directly."
281 )
282}
283
284fn defined_name(line: &str) -> Option<&str> {
286 let rest = line
287 .strip_prefix("def ")
288 .or_else(|| line.strip_prefix("async def "))?;
289 let name = rest.split('(').next()?.trim();
290 is_identifier(name).then_some(name)
291}
292
293fn imported_names(line: &str) -> Option<Vec<&str>> {
295 let rest = line.strip_prefix("from ")?;
296 let (_, names) = rest.split_once(" import ")?;
297 let names = names.trim().trim_start_matches('(').trim_end_matches(')');
298 Some(
299 names
300 .split(',')
301 .map(|n| n.split(" as ").last().unwrap_or(n).trim())
302 .filter(|n| !n.is_empty())
303 .collect(),
304 )
305}
306
307fn assigned_name(line: &str) -> Option<&str> {
309 let (name, rest) = line.split_once('=')?;
310 if rest.starts_with('=') {
311 return None;
312 }
313 let name = name.trim();
314 is_identifier(name).then_some(name)
315}
316
317fn strip_comment(line: &str) -> &str {
318 line.split('#').next().unwrap_or(line)
319}
320
321fn does_nothing(body: &str) -> bool {
324 let mut text = body.to_string();
325 for quote in ["\"\"\"", "\'\'\'"] {
326 while let Some(start) = text.find(quote) {
327 let Some(len) = text[start + 3..].find(quote) else {
328 return false;
329 };
330 text.replace_range(start..start + 3 + len + 3, "");
331 }
332 }
333 text.lines()
334 .map(|l| strip_comment(l).trim())
335 .filter(|l| !l.is_empty())
336 .all(|l| matches!(l, "pass" | "..." | "return" | "return None"))
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use serde_json::json;
343
344 fn availability() -> FunctionDeclaration {
345 FunctionDeclaration {
346 name: "check_availability".into(),
347 description: "Open table times.".into(),
348 parameters: Some(json!({
349 "type": "object",
350 "properties": {
351 "party_size": {"type": "integer"},
352 "date": {"type": "string", "description": "YYYY-MM-DD"},
353 "seating": {"type": "string"},
354 },
355 "required": ["date", "party_size"],
356 })),
357 behavior: None,
358 }
359 }
360
361 #[test]
362 fn a_stub_puts_required_parameters_first_in_their_declared_order() {
363 let stub = python_stub(&availability(), Some(r#"{"times": [str]}"#));
364 assert_eq!(
365 stub,
366 "def check_availability(date: str, party_size: int, seating: str | None = None) -> dict:\n \
367 \"\"\"Open table times.\n date: YYYY-MM-DD\n Returns {\"times\": [str]}\"\"\""
368 );
369 }
370
371 #[test]
372 fn positional_arguments_are_named_in_stub_order() {
373 let args = arguments(
374 &availability(),
375 vec![json!("2026-10-12"), json!(4)],
376 Map::new(),
377 )
378 .unwrap();
379 assert_eq!(args, json!({"date": "2026-10-12", "party_size": 4}));
380 }
381
382 #[test]
383 fn argument_errors_read_as_python_type_errors() {
384 let mut keywords = Map::new();
385 keywords.insert("date".into(), json!("2026-10-12"));
386 let twice = arguments(&availability(), vec![json!("2026-10-12")], keywords);
387 assert_eq!(
388 twice.unwrap_err(),
389 "TypeError: check_availability() got multiple values for argument 'date'"
390 );
391 let missing = arguments(&availability(), vec![json!("2026-10-12")], Map::new());
392 assert_eq!(
393 missing.unwrap_err(),
394 "TypeError: check_availability() missing required argument: 'party_size'"
395 );
396 let many = arguments(
397 &availability(),
398 vec![json!(1), json!(2), json!(3), json!(4)],
399 Map::new(),
400 );
401 assert!(
402 many.unwrap_err()
403 .contains("takes 3 positional arguments but 4")
404 );
405 }
406
407 #[test]
408 fn an_optional_argument_passed_as_none_is_left_out() {
409 let mut keywords = Map::new();
410 keywords.insert("seating".into(), Value::Null);
411 let args = arguments(&availability(), vec![json!("d"), json!(2)], keywords).unwrap();
412 assert_eq!(args, json!({"date": "d", "party_size": 2}));
413 }
414
415 #[test]
416 fn a_name_python_cannot_spell_goes_through_kwargs() {
417 let declaration = FunctionDeclaration {
418 name: "lookup".into(),
419 description: String::new(),
420 parameters: Some(json!({
421 "type": "object",
422 "properties": {"account-id": {"type": "STRING"}},
423 })),
424 behavior: None,
425 };
426 assert!(python_stub(&declaration, None).starts_with("def lookup(**kwargs) -> dict:"));
427 }
428
429 fn names(list: &[&str]) -> Vec<String> {
430 list.iter().map(ToString::to_string).collect()
431 }
432
433 #[test]
434 fn a_copied_stub_or_import_is_removed() {
435 let source = "import json\nfrom tools import check_availability\n\
436 def check_availability(date: str,\n party_size: int) -> dict:\n \
437 \"\"\"Open table times.\"\"\"\n pass # defined by the system\n\n\
438 times = check_availability('2026-10-12', 4)\ntimes";
439 let cleaned = without_redefinitions(source, &names(&["check_availability"])).unwrap();
440 assert_eq!(
441 cleaned,
442 "import json\ntimes = check_availability('2026-10-12', 4)\ntimes"
443 );
444 }
445
446 #[test]
447 fn a_real_redefinition_is_refused() {
448 let wraps = "def check_availability(date, party_size):\n return {'times': []}\n";
449 let err = without_redefinitions(wraps, &names(&["check_availability"])).unwrap_err();
450 assert!(err.contains("Delete your definition"), "{err}");
451 let mock = "check_availability = lambda d, p: {}\n";
452 assert!(without_redefinitions(mock, &names(&["check_availability"])).is_err());
453 let fine = "def helper(x):\n return x\nok = 1 == 1\nhelper(2)";
455 assert_eq!(
456 without_redefinitions(fine, &names(&["check_availability"])).unwrap(),
457 fine
458 );
459 }
460
461 #[test]
462 fn a_one_line_stub_is_removed_and_an_unfinished_one_left_alone() {
463 let one_line = "def lookup(account_id: str) -> dict: pass # the system's\nlookup('A1')";
464 assert_eq!(
465 without_redefinitions(one_line, &names(&["lookup"])).unwrap(),
466 "lookup('A1')"
467 );
468 let broken = "print(import json\ndef lookup(account_id: str -> dict\nlookup('A1')";
470 assert_eq!(
471 without_redefinitions(broken, &names(&["lookup"])).unwrap(),
472 broken
473 );
474 }
475
476 proptest::proptest! {
477 #[test]
478 fn no_program_text_makes_it_panic(source in "(def lookup|lookup|from t import lookup|[ ():\\n#'\"=.a-z]){0,40}") {
479 let _ = without_redefinitions(&source, &names(&["lookup"]));
480 }
481 }
482}