Skip to main content

gemini_adk_rs/code_mode/
stubs.rs

1//! Python stubs for tool declarations, and naming a call's arguments.
2//!
3//! A program sees each tool as a Python function. Its stub (signature and
4//! docstring) is generated from the tool's function declaration: required
5//! parameters first, in the order the declaration requires them, then the
6//! optional ones with a `None` default. A call's positional arguments are
7//! named in that same order, so `check_availability("2026-10-12", 4)` and
8//! `check_availability(date="2026-10-12", party_size=4)` reach the tool alike.
9
10use gemini_genai_rs::prelude::FunctionDeclaration;
11use serde_json::{Map, Value};
12
13/// One parameter of a declaration, as a program sees it.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct Parameter {
16    /// Name, as the declaration spells it.
17    pub name: String,
18    /// Python type annotation.
19    pub annotation: String,
20    /// Whether the declaration requires it.
21    pub required: bool,
22    /// The declaration's description of it, if any.
23    pub description: Option<String>,
24}
25
26/// The parameters of `declaration` in stub order: required ones first, in
27/// the order the declaration requires them, then the optional ones.
28pub 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
67/// The Python annotation for a JSON Schema property.
68fn 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
101/// The Python stub for `declaration`: its signature and a docstring with the
102/// tool's description, its parameters' descriptions and, when known, what it
103/// returns.
104pub 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        // A name Python cannot spell is passed as a keyword through **kwargs.
120        "**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
145/// Name a call's arguments for `declaration`: positional values in stub
146/// order, then the keywords. The errors are Python's, for the program.
147pub 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    // An optional parameter passed as None is left out, as the tool would
175    // see it in a direct call that omits it.
176    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
198/// `source` without its own stand-ins for the session's `functions`.
199///
200/// Shown Python stubs, a model sometimes copies them into its program
201/// (`def check_availability(date, party_size): pass`) or imports them
202/// (`from tools import check_availability`). Either shadows the real
203/// function, so every call returns `None` and the program fails far from the
204/// cause. A definition whose body does nothing (`pass`, `...`, a docstring)
205/// and an import of only these names are removed; any other definition or
206/// assignment of one of them is refused with the reason.
207pub 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            // A signature with no end is left for the interpreter to report.
217            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                // `def f(x): pass` on one line.
235                (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
258/// Where the signature of the `def` on line `start` ends: the line holding
259/// its closing `:` (the first outside brackets) and the text after it, an
260/// inline body. `None` when the program ends first.
261fn 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
284/// The name in `def name(`.
285fn 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
293/// The names in `from module import a, b as c` (the local names).
294fn 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
307/// The name in `name = ...` (not `==`).
308fn 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
321/// Whether a function body does nothing: only `pass`, `...`, `return`,
322/// `return None`, docstrings and comments.
323fn 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        // Other definitions and comparisons stay.
454        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        // From a live run: a stub whose signature never closes.
469        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}