Skip to main content

adk_tool/builtin/
bypass.rs

1//! Bypass wrapper for built-in tools (`bypass_multi_tools_limit`).
2//!
3//! The Gemini Interactions API rejects mixing custom function tools with
4//! built-in (server-side) tools such as `google_search` in a single request.
5//! ADK-Python solves this with `bypass_multi_tools_limit=True`, which converts a
6//! built-in tool into a *function-calling* tool so every tool in the request is
7//! uniform.
8//!
9//! [`BypassBuiltinTool`] is the Rust equivalent. It reports
10//! `is_builtin() == false`, exposes a normal function-calling parameter schema,
11//! and routes execution through an internal single-turn agent (reusing
12//! [`AgentTool`](crate::AgentTool)). The supplied agent is expected to be an
13//! `LlmAgent` configured with the corresponding built-in tool and a Gemini
14//! model, so the grounded behaviour (e.g. Google Search) is performed
15//! server-side and its result is returned as a function response — exactly
16//! ADK-Python's `GoogleSearchAgentTool` pattern.
17//!
18//! Because `adk-tool` cannot depend on `adk-agent` (that would be circular), the
19//! internal agent is supplied by the caller rather than constructed here.
20
21use crate::AgentTool;
22use adk_core::{Agent, Result, Tool, ToolContext};
23use async_trait::async_trait;
24use serde_json::{Value, json};
25use std::sync::Arc;
26
27/// Generalizes the `bypass_multi_tools_limit` conversion so any built-in tool
28/// can adopt it ergonomically.
29///
30/// The Gemini Interactions API forbids mixing built-in (server-side) tools with
31/// custom function tools in a single request. Implementing this trait lets a
32/// built-in tool be converted into a function-calling [`BypassBuiltinTool`] —
33/// reporting `is_builtin() == false` and declaring a normal function schema — so
34/// every tool in the request is uniform.
35///
36/// Implementors only describe *what* the bypass tool looks like (its name,
37/// description, parameter schema, and which argument field carries the query);
38/// the shared [`with_bypass_multi_tools_limit`](BypassMultiToolsLimit::with_bypass_multi_tools_limit)
39/// default method performs the actual conversion identically for every tool.
40/// This guarantees the conversion is uniform across `GoogleSearchTool`,
41/// `UrlContextTool`, `GeminiFileSearchTool`, and any future built-in.
42///
43/// Because `adk-tool` cannot depend on `adk-agent` (that would be circular), the
44/// internal agent that performs the built-in behaviour is supplied by the
45/// caller. It is expected to be an `LlmAgent` configured with the matching
46/// built-in tool and a Gemini model.
47///
48/// # Example
49///
50/// ```rust,ignore
51/// use adk_tool::{BypassMultiToolsLimit, GoogleSearchTool, UrlContextTool};
52/// use std::sync::Arc;
53///
54/// // `search_agent` / `url_agent` are LlmAgents with the matching built-in tool.
55/// let search = GoogleSearchTool::new().with_bypass_multi_tools_limit(Arc::new(search_agent));
56/// let url = UrlContextTool::new().with_bypass_multi_tools_limit(Arc::new(url_agent));
57/// assert!(!search.is_builtin());
58/// assert!(!url.is_builtin());
59/// ```
60pub trait BypassMultiToolsLimit: Sized {
61    /// The function-tool name surfaced to the model after bypass conversion.
62    ///
63    /// Defaults to the tool's own `Tool::name()` and rarely needs overriding.
64    fn bypass_name(&self) -> String;
65
66    /// The function-tool description surfaced to the model.
67    fn bypass_description(&self) -> String;
68
69    /// The JSON Schema for the bypass function's parameters.
70    fn bypass_parameters_schema(&self) -> Value;
71
72    /// The argument field that carries the natural-language query forwarded to
73    /// the internal agent (e.g. `"query"` for search, `"url"` for URL context).
74    fn bypass_query_field(&self) -> String;
75
76    /// Convert this built-in tool into a function-calling [`BypassBuiltinTool`]
77    /// so it can coexist with custom function tools under the Interactions API.
78    ///
79    /// `agent` is the internal single-turn agent that performs the built-in
80    /// behaviour and whose answer is returned as the function response.
81    fn with_bypass_multi_tools_limit(self, agent: Arc<dyn Agent>) -> Arc<dyn Tool> {
82        Arc::new(BypassBuiltinTool::new(
83            self.bypass_name(),
84            self.bypass_description(),
85            self.bypass_parameters_schema(),
86            self.bypass_query_field(),
87            agent,
88        ))
89    }
90}
91
92/// A built-in tool converted into a function-calling tool so it can coexist with
93/// custom function tools under the Gemini Interactions API.
94///
95/// The wrapper is provider-neutral and generic: any built-in tool can adopt it
96/// by supplying a name, description, parameter schema, the field that carries
97/// the natural-language query, and an internal agent that performs the built-in
98/// behaviour.
99///
100/// # Example
101///
102/// ```rust,ignore
103/// use adk_tool::GoogleSearchTool;
104/// use std::sync::Arc;
105///
106/// // `search_agent` is an LlmAgent configured with GoogleSearchTool + a Gemini model.
107/// let tool = GoogleSearchTool::new().with_bypass_multi_tools_limit(Arc::new(search_agent));
108/// assert!(!tool.is_builtin());
109/// ```
110pub struct BypassBuiltinTool {
111    name: String,
112    description: String,
113    parameters_schema: Value,
114    query_field: String,
115    inner: AgentTool,
116}
117
118impl BypassBuiltinTool {
119    /// Create a bypass wrapper around `agent`.
120    ///
121    /// * `name` / `description` — surfaced to the model as a function tool.
122    /// * `parameters_schema` — the JSON Schema for the function's parameters.
123    /// * `query_field` — the property in the incoming arguments that carries the
124    ///   natural-language query forwarded to the internal agent.
125    /// * `agent` — the internal single-turn agent that performs the built-in
126    ///   behaviour (e.g. an `LlmAgent` with the built-in Google Search tool).
127    pub fn new(
128        name: impl Into<String>,
129        description: impl Into<String>,
130        parameters_schema: Value,
131        query_field: impl Into<String>,
132        agent: Arc<dyn Agent>,
133    ) -> Self {
134        Self {
135            name: name.into(),
136            description: description.into(),
137            parameters_schema,
138            query_field: query_field.into(),
139            inner: AgentTool::new(agent).skip_summarization(true),
140        }
141    }
142
143    /// Extract the query string from the incoming tool arguments.
144    fn extract_query(&self, args: &Value) -> String {
145        if let Some(query) = args.get(&self.query_field).and_then(Value::as_str) {
146            return query.to_string();
147        }
148        match args {
149            Value::String(s) => s.clone(),
150            _ => serde_json::to_string(args).unwrap_or_default(),
151        }
152    }
153}
154
155#[async_trait]
156impl Tool for BypassBuiltinTool {
157    fn name(&self) -> &str {
158        &self.name
159    }
160
161    fn description(&self) -> &str {
162        &self.description
163    }
164
165    /// Bypass-converted tools are ordinary function-calling tools, never
166    /// built-in. This is what allows them to coexist with custom function tools.
167    fn is_builtin(&self) -> bool {
168        false
169    }
170
171    fn parameters_schema(&self) -> Option<Value> {
172        Some(self.parameters_schema.clone())
173    }
174
175    async fn execute(&self, ctx: Arc<dyn ToolContext>, args: Value) -> Result<Value> {
176        // Map the bypass tool's query argument onto AgentTool's `request` field
177        // and run the internal single-turn agent.
178        let query = self.extract_query(&args);
179        self.inner.execute(ctx, json!({ "request": query })).await
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::builtin::{GeminiFileSearchTool, GoogleSearchTool, UrlContextTool};
187    use adk_core::{
188        Artifacts, CallbackContext, Content, Event, EventActions, InvocationContext, MemoryEntry,
189        ReadonlyContext,
190    };
191    use std::sync::Mutex;
192
193    // Minimal single-turn agent that echoes a grounded answer.
194    struct MockSearchAgent;
195
196    #[async_trait]
197    impl Agent for MockSearchAgent {
198        fn name(&self) -> &str {
199            "google_search_agent"
200        }
201
202        fn description(&self) -> &str {
203            "Performs grounded Google search."
204        }
205
206        fn sub_agents(&self) -> &[Arc<dyn Agent>] {
207            &[]
208        }
209
210        async fn run(&self, _ctx: Arc<dyn InvocationContext>) -> Result<adk_core::EventStream> {
211            use async_stream::stream;
212            let s = stream! {
213                let mut event = Event::new("mock-inv");
214                event.author = "google_search_agent".to_string();
215                event.llm_response.content =
216                    Some(Content::new("model").with_text("grounded answer"));
217                yield Ok(event);
218            };
219            Ok(Box::pin(s))
220        }
221    }
222
223    struct MockToolContext {
224        actions: Mutex<EventActions>,
225        content: Content,
226    }
227
228    impl MockToolContext {
229        fn new() -> Self {
230            Self { actions: Mutex::new(EventActions::default()), content: Content::new("user") }
231        }
232    }
233
234    #[async_trait]
235    impl ReadonlyContext for MockToolContext {
236        fn invocation_id(&self) -> &str {
237            "inv-1"
238        }
239        fn agent_name(&self) -> &str {
240            "test-agent"
241        }
242        fn user_id(&self) -> &str {
243            "user-1"
244        }
245        fn app_name(&self) -> &str {
246            "test-app"
247        }
248        fn session_id(&self) -> &str {
249            "session-1"
250        }
251        fn branch(&self) -> &str {
252            ""
253        }
254        fn user_content(&self) -> &Content {
255            &self.content
256        }
257    }
258
259    #[async_trait]
260    impl CallbackContext for MockToolContext {
261        fn artifacts(&self) -> Option<Arc<dyn Artifacts>> {
262            None
263        }
264    }
265
266    #[async_trait]
267    impl ToolContext for MockToolContext {
268        fn function_call_id(&self) -> &str {
269            "call-1"
270        }
271        fn actions(&self) -> EventActions {
272            self.actions.lock().unwrap().clone()
273        }
274        fn set_actions(&self, actions: EventActions) {
275            *self.actions.lock().unwrap() = actions;
276        }
277        async fn search_memory(&self, _query: &str) -> Result<Vec<MemoryEntry>> {
278            Ok(vec![])
279        }
280    }
281
282    #[test]
283    fn bypass_reports_not_builtin() {
284        let tool = GoogleSearchTool::new().with_bypass_multi_tools_limit(Arc::new(MockSearchAgent));
285        assert!(!tool.is_builtin(), "bypass tool must report is_builtin() == false");
286    }
287
288    #[test]
289    fn bypass_declares_function_query_param() {
290        let tool = GoogleSearchTool::new().with_bypass_multi_tools_limit(Arc::new(MockSearchAgent));
291
292        assert_eq!(tool.name(), "google_search");
293
294        let schema = tool.parameters_schema().expect("bypass tool must declare a function schema");
295        assert_eq!(schema["type"], "object");
296        assert_eq!(schema["properties"]["query"]["type"], "string");
297        assert_eq!(schema["required"][0], "query");
298
299        // The declaration must be a plain function tool (no built-in metadata).
300        let decl = tool.declaration();
301        assert!(decl.get("x-adk-gemini-tool").is_none());
302        assert!(decl.get("parameters").is_some());
303    }
304
305    #[tokio::test]
306    async fn bypass_executes_via_internal_agent() {
307        let tool = GoogleSearchTool::new().with_bypass_multi_tools_limit(Arc::new(MockSearchAgent));
308        let ctx = Arc::new(MockToolContext::new()) as Arc<dyn ToolContext>;
309
310        let result = tool
311            .execute(ctx, json!({ "query": "what is adk-rust" }))
312            .await
313            .expect("bypass execution should succeed");
314
315        assert_eq!(result["response"], "grounded answer");
316    }
317
318    #[test]
319    fn url_context_bypass_reports_not_builtin_and_declares_url_param() {
320        let tool = UrlContextTool::new().with_bypass_multi_tools_limit(Arc::new(MockSearchAgent));
321
322        assert!(!tool.is_builtin(), "bypassed url_context must report is_builtin() == false");
323        assert_eq!(tool.name(), "url_context");
324
325        let schema = tool.parameters_schema().expect("bypass tool must declare a function schema");
326        assert_eq!(schema["type"], "object");
327        assert_eq!(schema["properties"]["url"]["type"], "string");
328        assert_eq!(schema["required"][0], "url");
329
330        // A plain function tool — no built-in metadata in the declaration.
331        let decl = tool.declaration();
332        assert!(decl.get("x-adk-gemini-tool").is_none());
333        assert!(decl.get("parameters").is_some());
334    }
335
336    #[test]
337    fn file_search_bypass_reports_not_builtin_and_declares_query_param() {
338        let tool = GeminiFileSearchTool::new(["my-store"])
339            .with_bypass_multi_tools_limit(Arc::new(MockSearchAgent));
340
341        assert!(!tool.is_builtin(), "bypassed file_search must report is_builtin() == false");
342        assert_eq!(tool.name(), "gemini_file_search");
343
344        let schema = tool.parameters_schema().expect("bypass tool must declare a function schema");
345        assert_eq!(schema["type"], "object");
346        assert_eq!(schema["properties"]["query"]["type"], "string");
347        assert_eq!(schema["required"][0], "query");
348
349        let decl = tool.declaration();
350        assert!(decl.get("x-adk-gemini-tool").is_none());
351        assert!(decl.get("parameters").is_some());
352    }
353
354    /// The generalized trait path must convert every adopting built-in tool into
355    /// a uniform, non-built-in function tool (Property 7: bypass conversion
356    /// uniformity) — demonstrated here across three different built-ins.
357    #[test]
358    fn trait_path_is_uniform_across_tools() {
359        let tools: Vec<Arc<dyn Tool>> = vec![
360            GoogleSearchTool::new().with_bypass_multi_tools_limit(Arc::new(MockSearchAgent)),
361            UrlContextTool::new().with_bypass_multi_tools_limit(Arc::new(MockSearchAgent)),
362            GeminiFileSearchTool::new(["store"])
363                .with_bypass_multi_tools_limit(Arc::new(MockSearchAgent)),
364        ];
365
366        for tool in &tools {
367            assert!(!tool.is_builtin(), "{} must not be built-in after bypass", tool.name());
368            assert!(
369                tool.parameters_schema().is_some(),
370                "{} must declare a function schema after bypass",
371                tool.name()
372            );
373            assert!(
374                tool.declaration().get("x-adk-gemini-tool").is_none(),
375                "{} must not retain built-in metadata after bypass",
376                tool.name()
377            );
378        }
379    }
380
381    #[tokio::test]
382    async fn url_context_bypass_executes_via_internal_agent() {
383        let tool = UrlContextTool::new().with_bypass_multi_tools_limit(Arc::new(MockSearchAgent));
384        let ctx = Arc::new(MockToolContext::new()) as Arc<dyn ToolContext>;
385
386        let result = tool
387            .execute(ctx, json!({ "url": "https://example.com" }))
388            .await
389            .expect("bypass execution should succeed");
390
391        assert_eq!(result["response"], "grounded answer");
392    }
393}