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.
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.
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-lock.txt"""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
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
{
"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-.
python -m unittest -v test_agent.pyRead the offline test
"""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
Read the explanation
For an authoritative package version, query the authoritative API directly rather than accepting a source name in generated prose.
- Query PostgreSQL through MCP Toolbox for Databases: answer from your own database through fixed, reviewed SQL tools.
References
Official sources checked October 21, 2026, against google-adk 2.12.0 and google-genai 2.32.0.
- ADK: Google Search
- ADK: Understanding Google Search grounding
- ADK: Gemini API code execution
- ADK: Limitations for ADK tools
- Gemini API: Combine built-in tools and function calling
- Gemini API: Grounding with Google Search
- Gemini API: Code execution
Previously on the blog: Google ADK Built-in Tools: Google Search & Code Executor covers the ADK 1.x version of these tools.