aiml
SearchMy learning
Menu

Lesson 2 of 6Module 1

Use Gemini built-in tools: Google Search grounding and code execution

Add Gemini's Google Search and code execution to an ADK agent beside a function tool, cite answers from grounding metadata, and handle request errors.

Prerequisites

An ADK agent with function tools, as in Building Agents with Google ADK 2.0.

By the end, you can

  • Add google_search and BuiltInCodeExecutor to an ADK agent that also has function tools.
  • Read grounding_metadata and build citations from grounding_supports instead of the answer text.
  • Explain which model ids ADK accepts for built-in tools, and what the Gemini API needs to combine them with function calling.
Runtime
Python 3.12+, a Gemini API key
Dependencies
google-adk 2.12.0 with google-genai 2.32.0; litellm 1.102.4 only for the refused-model check

Lab code, tests and fixtures (ZIP, 18 KB)Quiz check your understanding

Progress is saved in this browser.

The docs assistant needs two kinds of checks: whether a dependency pin is current, and whether the deploy timings meet the service’s SLO. It reads the bundled pages with read_page, uses Google Search for changing facts and asks Gemini’s code executor to calculate the statistics. Search and code execution run on Google’s servers; the Python application receives their results and grounding metadata through ADK events.

When you need this. When a docs assistant has to check a page against the outside world, such as “is this pin still the latest release?”, or compute a statistic from a page, such as a p95 from a list of deploy timings, we use the model’s own search and code sandbox instead of running a search client or a Python interpreter next to the agent.

Google hosts search and code execution. ADK configures these tools in the Gemini request; the local Python process handles only its own function tools.

Local function tools and Gemini’s built-in tools
data flowreturnartifact hand-offfailureagent processdocs_assistanttools: read_page, google_searchcode_executor: BuiltInCodeExecutor()read_pagefunction toolcallresultnot gemini-*ValueError, no requestGemini API (server-side)gemini-3.8-flashfunction_declarations, google_search,code_execution, tool_config flagrequestresponseGoogle Searchtool_call GOOGLE_SEARCH_WEBcode executionexecutable_code, resultwithout include_server_side_tool_invocations: 400 INVALID_ARGUMENTdata flowreturnartifact hand-offfailureagent processdocs_assistantread_pagecallnot gemini-*ValueErrorrequest + flagGemini APIGeminigemini-3.8-flashGoogleSearchcodeexecutionno flag400 INVALID_ARGUMENT
Google Search and code execution are not functions ADK runs. ADK adds them to the request as built-in tools, Gemini runs them on its own servers, and the response carries what happened: tool_call and tool_response parts for search, executable_code and code_execution_result parts for code, and grounding_metadata with the sources. Only read_page runs in your process. The answer text can name a source the grounding does not list, so cite from grounding_metadata.

Add the built-in tools

Download the complete lab ZIP, extract it, and open a terminal in adk-built-in-tools/. The commands below use that directory and its pinned dependencies. Set GOOGLE_API_KEY in this terminal. This lesson has no local Gemma option: Gemini runs these tools, and ADK refuses them for other models.

Prepare the Gemini tools labTerminal · bash
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-lock.txt
agent.pySource · pythonDownload
"""The docs assistant with Gemini's built-in Google Search and code execution."""
import asyncio
import os
import sys
from pathlib import Path

from google.adk import Agent, Runner
from google.adk.code_executors import BuiltInCodeExecutor
from google.adk.sessions import InMemorySessionService
from google.adk.tools import google_search
from google.genai import types

HERE = Path(__file__).resolve().parent
MODEL = os.environ.get("DOCS_MODEL", "gemini-3.8-flash")  # provider-hosted Gemini tools: no Ollama option
PAGES = sorted(p.stem for p in (HERE / "docs").glob("*.md"))


def read_page(page: str) -> dict:
    """Read one page of the docuctl documentation: dependencies or deploy-timings."""
    if page not in PAGES:
        return {"error": f"No page {page!r}. Pages: {', '.join(PAGES)}."}
    root = (HERE / "docs").resolve()
    path = (root / f"{page}.md").resolve()
    if not path.is_relative_to(root) or not path.is_file():
        return {"error": f"No readable docs page {page!r}."}
    return {"page": page, "text": path.read_text(encoding="utf-8")}


def build_agent(model=None, combine: bool = True, tools=None, code: bool = True) -> Agent:
    # Gemini runs google_search and code execution server-side. Mixing them with read_page, a function
    # tool, in one request needs include_server_side_tool_invocations (the API returns 400 without it).
    config = types.GenerateContentConfig(temperature=0.2, thinking_config=types.ThinkingConfig(thinking_level="low"))
    if combine:
        config.tool_config = types.ToolConfig(include_server_side_tool_invocations=True)
    return Agent(
        name="docs_assistant",
        model=model or MODEL,
        instruction="You help developers keep the docuctl documentation correct. Read pages with read_page. "
                    "Use Google Search for facts that change, such as the latest release of a package. "
                    "Use code for any arithmetic or statistics. Say which page or source each fact comes from.",
        tools=tools if tools is not None else [read_page, google_search],
        code_executor=BuiltInCodeExecutor() if code else None,
        generate_content_config=config,
    )


async def ask(text: str, agent: Agent | None = None) -> list:
    sessions = InMemorySessionService()
    runner = Runner(app_name="docs", agent=agent or build_agent(), session_service=sessions)
    try:
        session = await sessions.create_session(app_name="docs", user_id="dev")
        message = types.Content(role="user", parts=[types.Part(text=text)])
        return [e async for e in runner.run_async(user_id="dev", session_id=session.id, new_message=message)]
    finally:
        await runner.close()


def sources(events: list) -> list[dict]:
    """The web sources Gemini grounded its answer on, from the last event that carries them."""
    grounded = [e.grounding_metadata for e in events if e.grounding_metadata and e.grounding_metadata.grounding_chunks]
    if not grounded:
        return []
    meta = grounded[-1]
    return [{"title": c.web.title, "uri": c.web.uri} for c in meta.grounding_chunks if c.web]


def citations(events: list) -> list[dict]:
    """Each answer segment with the sources Gemini says support it, from grounding_supports."""
    grounded = [e.grounding_metadata for e in events if e.grounding_metadata and e.grounding_metadata.grounding_supports]
    if not grounded:
        return []
    meta = grounded[-1]
    titles = [c.web.title if c.web else "?" for c in meta.grounding_chunks or []]
    return [{"text": s.segment.text, "sources": [titles[i] for i in s.grounding_chunk_indices or []]}
            for s in meta.grounding_supports]


def show(events: list) -> str:
    lines = []
    for event in events:
        for part in (event.content.parts if event.content else None) or []:
            if part.tool_call:  # a built-in tool Gemini ran server-side, returned because of the flag
                lines.append(f"server {part.tool_call.tool_type.name} {part.tool_call.args}")
            if part.function_call:
                lines.append(f"call   {part.function_call.name} {part.function_call.args}")
            if part.function_response:
                lines.append(f"result {part.function_response.name} {sorted(part.function_response.response)}")
            if part.executable_code:
                lines.append("code   " + part.executable_code.code.strip().replace("\n", "\n       "))
            if part.code_execution_result:
                result = part.code_execution_result
                lines.append(f"output {result.outcome.name} {(result.output or '').strip()}")
        meta = event.grounding_metadata
        if meta and meta.web_search_queries:
            lines.append(f"search {meta.web_search_queries}")
        if event.is_final_response() and event.author != "user" and event.content:
            text = "".join(p.text or "" for p in event.content.parts if p.text and not p.thought).strip()
            if text:
                lines.append("answer " + text)
    for source in sources(events):
        lines.append(f"source {source['title']}")
    return "\n".join(lines)


if __name__ == "__main__":
    print(show(asyncio.run(ask(" ".join(sys.argv[1:])))))

google_search is a ready-made GoogleSearchTool, and BuiltInCodeExecutor() is set on the agent’s code_executor, not in tools. Neither one runs code in your process. Each adds an entry to the request’s tools list, google_search and code_execution, and Gemini does the rest.

The tool_config line is the one that makes this agent work. The agent mixes built-in tools with read_page, a function tool that the agent runs itself, and the Gemini API rejects that mix unless include_server_side_tool_invocations is set. The Gemini API calls this combining built-in tools and function calling, and documents it as a Preview feature for Gemini 3 and Gemini 4 models, so check the tool combination when you change the model or SDK version.

Built-in and function tools without the flag. The recorded Gemini request returns 400 INVALID_ARGUMENT and raises ClientError. Set tool_config.include_server_side_tool_invocations, or use the separate-agent search workaround described below.

A model that is not Gemini. ADK raises ValueError before sending a request. Use a Gemini model for these built-ins; a local model needs its own search or sandbox function tool.

Ask a grounded question and a computed one

Terminal commandsTerminal · bash
python agent.py "Is the google-adk pin on the dependencies page current?"
python agent.py "Do the deploy timings meet the SLO?"

For the version question, inspect the server, search and source lines. They show the server-side search, query list and grounded sources. For the SLO question, inspect the generated Python and its output before accepting the percentile. The fixture answer uses 118.2 seconds; a different percentile definition can change the result, so define the method in the instruction. The code sandbox receives the values Gemini puts into the program. Review what your local function returns before sending private data.

An answer that names a source grounding does not list. The recorded text attributes the version to PyPI, but the chunks name other sites. Draw citations from grounding_supports, and query an authoritative source directly when the application needs to establish a release number.

For a package-version check, call the PyPI JSON API directly from a function tool. Grounding metadata identifies the sources returned by search; it does not verify every source name in the generated text.

Read server-side tool parts and grounding metadata

ADK builds one request. read_page becomes a function declaration, GoogleSearchTool.process_llm_request appends google_search, and the code executor appends code_execution:

Inspect the saved test fixture
recorded-runs.json: what ADK sent in the first requestRecorded output · jsonDownload
{
  "tools": [
    [
      "code_execution"
    ],
    [
      "function_declarations"
    ],
    [
      "google_search"
    ]
  ],
  "tool_config": {
    "include_server_side_tool_invocations": true
  }
}

The response tells you what Gemini did on its side. With the flag on, a search comes back as a tool_call part with tool_type GOOGLE_SEARCH_WEB and a matching tool_response; code comes back as executable_code and code_execution_result parts. ADK passes all of these through as event content. The final response also carries grounding_metadata: grounding_chunks lists the web sources, and each grounding_supports entry maps a segment of the answer text to the chunks that support it:

grounding_metadata.search_entry_point.rendered_content holds the Search suggestions as HTML, 4500 characters in this run. The adk.dev Google Search page says you must display those suggestions in production applications that show grounded answers, so keep that HTML with the answer. Each chunk’s uri is a vertexaisearch.cloud.google.com redirect; show it as the link, and do not store it as the page’s canonical URL.

Model compatibility. The pinned ADK tools require a model ID beginning with gemini-. That client check alone does not guarantee that the provider supports a particular combination of tools. Use the pinned model for this activity and check the provider’s tool-combination documentation before changing it.

The other way to combine search with tools. GoogleSearchTool(bypass_multi_tools_limit=True) is ADK’s own workaround: when the agent has other tools, ADK replaces search with a google_search_agent tool that runs a separate search-only agent. It needs no preview flag, but every search is one more model call and one more round of tokens. Compare the extra search-agent turn when deciding whether to use this approach.

Test the agent offline

test_agent.py replays the recorded Gemini responses, including the search and code parts and the grounding metadata, through ADK’s real request building and event handling. The replay model is named like the recorded one, because ADK refuses built-in tools for any id that does not start with gemini-.

Terminal commandsTerminal · bash
python -m unittest -v test_agent.py
Read the offline test
test_agent.pySource · pythonDownload
"""Replay the recorded Gemini responses, offline, through the real ADK agent loop.

Run: python -m unittest -v test_agent.py
Gemini ran Google Search and code execution server-side when recording; the replay feeds those
responses back, so read_page, ADK's request building and the grounding parsing are real.
"""
import asyncio
import json
import logging
from unittest.mock import patch
import unittest
import tempfile
import warnings
from pathlib import Path

warnings.filterwarnings("ignore")
logging.disable(logging.CRITICAL)

from google.adk.models.base_llm import BaseLlm  # noqa: E402
from google.adk.models.lite_llm import LiteLlm  # noqa: E402
from google.adk.models.llm_response import LlmResponse  # noqa: E402
from google.adk.tools.google_search_tool import GoogleSearchTool  # noqa: E402

import agent  # noqa: E402

RECORD = json.loads(Path(__file__).with_name("recorded-runs.json").read_text(encoding="utf-8"))
TEXT = {"content": {"role": "model", "parts": [{"text": "ok"}]}}


def setUpModule():
    warnings.simplefilter("ignore")  # unittest resets warning filters before the tests run


class ReplayLlm(BaseLlm):
    responses: list
    requests: list = []

    async def generate_content_async(self, llm_request, stream=False):
        self.requests.append(llm_request)
        yield LlmResponse.model_validate(self.responses.pop(0))


def replay(responses: list, **build) -> tuple[list, ReplayLlm]:
    # Named like the recorded model: ADK refuses built-in tools for a model id that is not gemini-*.
    model = ReplayLlm(model=RECORD["model"], responses=list(responses), requests=[])
    return asyncio.run(agent.ask("replayed question", agent.build_agent(model, **build))), model


def request_tools(request) -> list[str]:
    return sorted(k for t in request.config.tools for k in t.model_dump(exclude_none=True))


class BuiltInToolsTests(unittest.TestCase):
    def test_the_pin_check_replays_with_its_grounding_sources(self):
        run = RECORD["runs"]["pin"]
        events, _ = replay(run["responses"])
        self.assertEqual(agent.show(events), run["transcript"])
        self.assertEqual(agent.sources(events), run["sources"])
        self.assertIn("server GOOGLE_SEARCH_WEB", agent.show(events))

    def test_built_in_and_function_tools_share_one_request(self):
        _, model = replay(RECORD["runs"]["pin"]["responses"])
        self.assertEqual(request_tools(model.requests[0]), ["code_execution", "function_declarations", "google_search"])
        self.assertTrue(model.requests[0].config.tool_config.include_server_side_tool_invocations)

    def test_code_execution_runs_on_gemini_and_returns_its_output(self):
        run = RECORD["runs"]["slo"]
        events, _ = replay(run["responses"])
        transcript = agent.show(events)
        self.assertEqual(transcript, run["transcript"])
        self.assertIn("output OUTCOME_OK", transcript)
        self.assertIn("p95 (linear): 118.2", transcript)

    def test_without_the_flag_gemini_rejects_built_in_plus_function_tools(self):
        self.assertIn("include_server_side_tool_invocations", RECORD["failures"]["no_flag"])
        self.assertTrue(RECORD["failures"]["no_flag"].startswith("ClientError: 400 INVALID_ARGUMENT"))
        _, model = replay([TEXT], combine=False)
        self.assertIsNone(model.requests[0].config.tool_config)
        self.assertIn("google_search", request_tools(model.requests[0]))

    def test_a_non_gemini_model_is_refused_before_any_call(self):
        assistant = agent.build_agent(LiteLlm(model="ollama_chat/gemma4:e2b"), code=False)
        with self.assertRaises(ValueError) as caught:
            asyncio.run(agent.ask("replayed question", assistant))
        self.assertEqual(f"ValueError: {caught.exception}", RECORD["failures"]["gemma"])

    def test_the_answer_names_a_source_that_grounding_does_not_list(self):
        run = RECORD["runs"]["pin"]
        titles = [s["title"] for s in run["sources"]]
        self.assertIn("PyPI", run["transcript"].split("answer ", 1)[1])
        self.assertNotIn("pypi.org", titles)
        self.assertTrue(all(c["sources"] for c in run["citations"]))

    def test_bypass_wraps_search_in_a_sub_agent_tool(self):
        tools = [agent.read_page, GoogleSearchTool(bypass_multi_tools_limit=True)]
        _, model = replay([TEXT], combine=False, code=False, tools=tools)
        declared = sorted(d.name for t in model.requests[0].config.tools for d in t.function_declarations or [])
        self.assertEqual(declared, ["google_search_agent", "read_page"])
        self.assertEqual(request_tools(model.requests[0]), ["function_declarations"])


class DocsPathSafety(unittest.TestCase):
    def test_read_page_refuses_an_out_of_tree_symlink(self):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            (root / "docs").mkdir()
            (root / "outside.txt").write_text("private content")
            (root / "docs/config.md").symlink_to(root / "outside.txt")
            with patch.object(agent, "HERE", root), patch.object(agent, "PAGES", ["config"]):
                result = agent.read_page("config")
            self.assertIn("error", result)
            self.assertNotIn("private content", str(result))


if __name__ == "__main__":
    unittest.main()

Check your understanding

The answer names PyPI, but its grounding metadata lists different sources. Which citations should the UI show?

Choose one answer, then check your reasoning.

Read the explanation

For an authoritative package version, query the authoritative API directly rather than accepting a source name in generated prose.

References

Official sources checked October 21, 2026, against google-adk 2.12.0 and google-genai 2.32.0.

Previously on the blog: Google ADK Built-in Tools: Google Search & Code Executor covers the ADK 1.x version of these tools.

Written by Arjun Prabhulal.

Module 1

Add Skills and built-in Gemini tools