A voice assistant needs tools when the answer depends on a document lookup or a job outside the model. A blocking lookup waits for its result; a background export lets the conversation continue; a streaming tool can return updates until it is stopped. This lab implements all three for the documentation assistant.
The saved connections use gemini-3.8-live. In the recorded tool batch on gemini-3.8-live, the spoken lookup called search_docs and answered The Live API connection closes after about ten minutes, but you can enable session resumption to continue.. The caller stopped the Markdown-link inventory while it was running, and the model announced the export’s completion.
When you need this. Use a tool when a voice assistant needs current docs or a job that outlasts one spoken answer. Inspect function responses and spoken transcripts separately: completing a tool does not establish what the caller heard.
1. function response
3. via Runner.run_live
gemini-3.8-live.Download the complete lab ZIP, extract it and open a terminal in adk-live-tools/. Create the environment below and set GOOGLE_API_KEY in that terminal before running a live command. docs/ contains three pages; lookup.wav is the spoken question.
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-lock.txt1. Write the three kinds of tool
"""Live tools for the docs assistant: a blocking search, a non-blocking export and a streaming link check."""
import asyncio
import os
import re
from pathlib import Path
from typing import AsyncGenerator
from google.adk.agents import Agent
from google.adk.tools import FunctionTool
from google.genai import types
# Use the current course model; historical 2.5/3.1 tool experiments remain in tools-run-historical.json.
TOOLS_MODEL = os.getenv("LIVE_MODEL", "gemini-3.8-live")
DOCS = Path(__file__).parent / "docs"
SLOW_S = 6.0 # the tests shorten this
def search_docs(query: str) -> dict:
"""Search the docs pages and rank matching lines by the number of query words they contain.
Args:
query: A few keywords, for example "connection minutes".
"""
words = {w for w in re.findall(r"\w+", query.lower()) if len(w) > 3}
scored = sorted(((sum(w in line.lower() for w in words), page.name, line.strip("- "))
for page in sorted(DOCS.glob("*.md")) for line in page.read_text().splitlines()), reverse=True)
hits = [{"page": page, "line": line} for score, page, line in scored[:3] if score]
return {"query": query, "hits": hits} if hits else {"query": query, "hits": [], "note": "no page mentions this"}
async def export_page_index() -> dict:
"""Export the list of docs pages for the search index. Takes several seconds."""
await asyncio.sleep(SLOW_S)
return {"status": "done", "pages": sorted(p.name for p in DOCS.glob("*.md"))}
async def check_links() -> AsyncGenerator[str, None]:
"""Count Markdown links in each docs page and report progress. This does not fetch or validate URLs."""
pages = sorted(DOCS.glob("*.md"))
for number, page in enumerate(pages, 1):
await asyncio.sleep(SLOW_S / len(pages))
links = re.findall(r"\]\(([^)]+)\)", page.read_text())
yield f"Checked {page.name} ({number} of {len(pages)}): {len(links)} links."
yield f"Done: {len(pages)} pages checked."
def stop_streaming(function_name: str):
"""Stop a running streaming tool. ADK intercepts this call by name.
Args:
function_name: The name of the streaming tool to stop.
"""
def build_agent(model=TOOLS_MODEL) -> Agent:
lookup = FunctionTool(search_docs)
# Gemini3.8 Live defaults to async function calls unless BLOCKING is explicit.
lookup.behavior = types.Behavior.BLOCKING
export = FunctionTool(export_page_index)
# The Live API returns the result when the model is idle, instead of blocking the conversation.
export.response_scheduling = types.FunctionResponseScheduling.WHEN_IDLE
return Agent(name="docs_helper", model=model, tools=[lookup, export, check_links, FunctionTool(stop_streaming)],
instruction=("You are the docs assistant on a voice call. Look facts up with search_docs before you "
"answer, and answer in one or two short sentences. Start export_page_index or check_links "
"only when asked, call each once, and keep talking while they run."))Blocking. search_docs is a FunctionTool with behavior = BLOCKING. Gemini 3.8 Live defaults to asynchronous function calls, so set the declaration explicitly when the model must wait for a lookup. The function returns at most three matching lines, or a note when nothing matches. The current connection log records BLOCKING for this declaration.
Non-blocking. export_page_index is an async function with a plain return. ADK marks it NON_BLOCKING; response_scheduling = WHEN_IDLE asks the Live API to deliver the result at a pause. The current model supports scheduling. The recording below shows one completed export and its spoken notification.
Streaming. check_links is an async generator that counts Markdown links and yields progress. It does not request URLs or detect broken destinations. ADK starts it as a background task and returns each yield as a function response. stop_streaming is the reserved tool name that asks ADK to cancel that task; its body stays empty.
Lookup assumed blocking. Leaving the declaration unspecified uses the model’s asynchronous default. Set FunctionTool.behavior explicitly; test_lookup_explicitly_declares_blocking_for_current_live_model fails when that assignment is removed.
A tool that raises. The current read_page("deploy.md") run ended with FileNotFoundError: [Errno 2] No such file or directory: 'docs/deploy.md'. Catch expected errors in the tool and return them as data, such as an error field. test_a_raising_tool_ends_the_whole_live_session replays the original failure; the current batch also records it.
2. Mark the spoken turn
For this finite spoken question, reuse the media lesson’s explicit activity signals. LOOKUP_CONFIG disables automatic activity detection, and push_to_talk sends activity start, the PCM frames, then activity end. The other conversations send complete text turns.
Spoken clip waits for a turn end. Without explicit activity boundaries on this short pre-recorded clip, the connection received an activity-start event but no completed transcript or tool call before TimeoutError: no further turn within 90 s. With automatic detection disabled and explicit send_activity_start() / send_activity_end() signals, the recorded lookup completed. Check: test_push_to_talk_marks_both_activity_boundaries, test_activity_signals_complete_the_recorded_lookup.
3. Record the conversations
python record.pyThe lookup returned a source line before the model’s spoken answer. Its tool events are separate from its audio and transcript events:
[
{
"author": "docs_helper",
"calls": [
{
"name": "search_docs",
"args": {
"query": "Live API connection duration"
}
}
]
},
{
"author": "docs_helper",
"responses": [
{
"name": "search_docs",
"response": {
"query": "Live API connection duration",
"hits": [
{
"page": "live-streaming.md",
"line": "`Runner.run_live()` holds one open connection to the Gemini Live API while the user talks."
},
{
"page": "live-streaming.md",
"line": "A Live API connection closes after about 10 minutes; enable session resumption to continue."
},
{
"page": "tools.md",
"line": "ADK runs a live agent's tool calls itself and sends each result back to the model."
}
]
}
}
]
}
]In this batch, check_links returned its pending status and the model said I've started checking the links in the documentation pages and will let you know once it's complete.. The caller then asked to stop; ADK returned Successfully stopped streaming function check_links. The stop arrived before the first progress yield.
The export ran alongside a lookup of the input sample rate. Its result went back to the model, and the finished transcript was The input sample rate is 16 kHz, and the page index export has been started.The page index export has successfully finished processing.. Display completion in the client if your UI requires deterministic job status alongside the spoken notification.
Read record.py
"""Record current Live tool runs; optionally include the historical model negative control."""
import argparse
import asyncio
import json
from datetime import datetime, timezone
from pathlib import Path
from google.adk.agents import Agent
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google import genai
from google.genai import types
from live_harness import RecordingGemini, environment, MODEL, PACE, read_pcm, say_audio, say_text, spoken, talk, wait_for_turn
from live_tools import TOOLS_MODEL, build_agent
HERE = Path(__file__).parent
# Explicit historical negative control, not the runnable agent default.
HISTORICAL_MODEL = "gemini-2.5-flash-native-audio-preview-12-2025"
from google.adk.agents.run_config import RunConfig
RUN_CONFIG = RunConfig(response_modalities=["AUDIO"])
LOOKUP_CONFIG = RunConfig(response_modalities=["AUDIO"], realtime_input_config=types.RealtimeInputConfig(
automatic_activity_detection=types.AutomaticActivityDetection(disabled=True)))
def read_page(name: str) -> dict:
"""Return the text of one docs page.
Args:
name: The page file name, for example "tools.md".
"""
return {"name": name, "text": (HERE / "docs" / name).read_text()}
async def run(name, turns, agent_factory=build_agent, model_id=TOOLS_MODEL, timeout=90, run_config=RUN_CONFIG):
model = RecordingGemini(model=model_id, connections=[])
runner = Runner(app_name="voice_docs", agent=agent_factory(model), session_service=InMemorySessionService())
session = await runner.session_service.create_session(app_name="voice_docs", user_id="dev")
entry = {"name": name}
try:
rows = await talk(runner, session.id, run_config, turns, timeout=timeout)
except asyncio.TimeoutError as error:
rows, entry["error"] = error.rows, f"TimeoutError: no further turn within {timeout} s"
except Exception as error:
message = str(error).splitlines()[0].replace(str(HERE) + "/", "") # keep local paths out of the record
rows, entry["error"] = [], f"{type(error).__name__}: {message[:240]}"
entry.update(events=rows, answer=spoken(rows), connections=model.connections)
calls = [c["name"] for r in rows for c in r.get("calls", [])]
print(f"[{name}] calls={calls} said={entry['answer'][:200]!r}" + (f" error={entry['error']}" if "error" in entry else ""))
return entry
def push_to_talk(pcm):
"""Send one finite question with the same explicit activity signals as the media lesson."""
async def send(queue):
queue.send_activity_start()
for start in range(0, len(pcm), 3200):
queue.send_realtime(types.Blob(mime_type="audio/pcm;rate=16000", data=pcm[start:start + 3200]))
await asyncio.sleep(PACE * 0.1)
queue.send_activity_end()
return send
def page_reader(model):
return Agent(name="docs_helper", model=model, tools=[read_page],
instruction="You are the docs assistant. Use read_page to read pages. Answer in one short sentence.")
async def raw_tool_call(model_id, timeout=25):
"""Without ADK: which server messages follow a tool call on this model."""
declaration = types.FunctionDeclaration(name="search_docs", description="Search the docs pages.", parameters=types.Schema(
type="OBJECT", properties={"query": types.Schema(type="STRING")}, required=["query"]))
config = types.LiveConnectConfig(response_modalities=["AUDIO"], system_instruction="Use search_docs to look facts up.",
tools=[types.Tool(function_declarations=[declaration])])
seen = []
async with genai.Client().aio.live.connect(model=model_id, config=config) as live:
await live.send_client_content(turns=types.Content(role="user", parts=[types.Part(text=LOOKUP)]))
async def receive():
async for message in live.receive():
if message.tool_call:
seen.append("tool_call")
if message.server_content and message.server_content.turn_complete:
seen.append("turn_complete")
return
try:
await asyncio.wait_for(receive(), timeout)
except asyncio.TimeoutError:
seen.append(f"nothing more in {timeout} s")
print(f"[raw Live API, {model_id}] {seen}")
return {"model": model_id, "messages": seen}
LOOKUP = "How long does a Live API connection stay open? Look it up."
async def main(include_historical_models=False):
started_at = datetime.now(timezone.utc).isoformat()
runs = [
await run("blocking lookup", [push_to_talk(read_pcm(HERE / "lookup.wav"))], run_config=LOOKUP_CONFIG),
await run("streaming link check, then stop", [say_text("Check the links in the docs pages."),
wait_for_turn(), say_text("Never mind, stop the link check.")], timeout=60),
await run("scheduled export on current Live model",
[say_text("Start the page index export. While it runs, tell me the input sample rate."), wait_for_turn()], timeout=60),
await run("tool raises an exception", [say_text("Read the page deploy.md.")], agent_factory=page_reader, timeout=40),
]
raw = [await raw_tool_call(TOOLS_MODEL)]
if include_historical_models:
runs.append(await run("tools on Gemini 2.5 Flash Live", [say_text(LOOKUP)], model_id=HISTORICAL_MODEL, timeout=40))
raw.append(await raw_tool_call(HISTORICAL_MODEL))
(HERE / "tools-run.json").write_text(json.dumps({"recorded_at": started_at, "environment": environment() | {"tools_model": TOOLS_MODEL},
"runs": runs, "raw_tool_call": raw}, indent=1) + "\n")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--include-historical-models", action="store_true",
help="Also rerun the explicitly historical Gemini 2.5 negative control")
asyncio.run(main(parser.parse_args().include_historical_models))4. Test it without the network
Inspect the saved session output
[blocking lookup] calls=['search_docs'] said='The Live API connection closes after about ten minutes, but you can enable session resumption to continue.'
[streaming link check, then stop] calls=['check_links', 'stop_streaming'] said="I've started checking the links in the documentation pages and will let you know once it's complete."
[scheduled export on current Live model] calls=['export_page_index', 'search_docs'] said='The input sample rate is 16 kHz, and the page index export has been started.The page index export has successfully finished processing.'
[tool raises an exception] calls=[] said='' error=FileNotFoundError: [Errno 2] No such file or directory: 'docs/deploy.md'
[raw Live API, gemini-3.8-live] ['tool_call', 'turn_complete']python -m unittest -v test_live_tools.pytest_a_raising_tool_ends_the_whole_live_session (test_live_tools.LiveTools.test_a_raising_tool_ends_the_whole_live_session) ... /Users/arjunprabhulal/aiml-lanes/envs/venv-agents/lib/python3.12/site-packages/google/adk/models/llm_request.py:306: UserWarning: [EXPERIMENTAL] feature FeatureName.JSON_SCHEMA_FOR_FUNC_DECL is enabled.
declaration = tool._get_declaration()
ok
test_a_stop_after_the_stream_finished_finds_nothing (test_live_tools.LiveTools.test_a_stop_after_the_stream_finished_finds_nothing) ... ok
test_activity_signals_complete_the_recorded_lookup (test_live_tools.LiveTools.test_activity_signals_complete_the_recorded_lookup) ... ok
test_adk_runs_the_lookup_and_sends_the_result_back (test_live_tools.LiveTools.test_adk_runs_the_lookup_and_sends_the_result_back) ... ok
test_async_tools_are_declared_non_blocking (test_live_tools.LiveTools.test_async_tools_are_declared_non_blocking) ... ok
test_each_streamed_update_goes_back_to_the_model (test_live_tools.LiveTools.test_each_streamed_update_goes_back_to_the_model) ... ok
test_gemini_2_5_tool_calls_never_reach_adk (test_live_tools.LiveTools.test_gemini_2_5_tool_calls_never_reach_adk) ... ok
test_lookup_explicitly_declares_blocking_for_current_live_model (test_live_tools.LiveTools.test_lookup_explicitly_declares_blocking_for_current_live_model) ... ok
test_push_to_talk_marks_both_activity_boundaries (test_live_tools.LiveTools.test_push_to_talk_marks_both_activity_boundaries) ... ok
test_scheduled_result_on_gemini_3_1_is_never_announced (test_live_tools.LiveTools.test_scheduled_result_on_gemini_3_1_is_never_announced) ... ok
test_search_reports_a_miss_instead_of_inventing_a_hit (test_live_tools.LiveTools.test_search_reports_a_miss_instead_of_inventing_a_hit) ... ok
test_streamed_updates_reach_the_model_but_are_not_narrated (test_live_tools.LiveTools.test_streamed_updates_reach_the_model_but_are_not_narrated) ... ok
test_the_pending_status_is_spoken_as_its_own_turn (test_live_tools.LiveTools.test_the_pending_status_is_spoken_as_its_own_turn) ... ok
----------------------------------------------------------------------
Ran 13 tests in 4.386s
OKAll 13 tests pass offline by replaying recorded connections through ADK’s run_live loop and verifying tool declarations, activity boundaries, cancellation and error propagation.
Read the test source
"""Replay the recorded tool calls through ADK's run_live loop; ADK runs the real tools. No network."""
import asyncio
import copy
import json
import logging
import unittest
from pathlib import Path
from google.adk.agents.live_request_queue import LiveRequestQueue
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.adk.tools import FunctionTool
from google.genai import types
import live_harness
import live_tools
from live_harness import ReplayLive, read_pcm, say_audio, say_text, talk, wait_for_turn
from live_tools import build_agent, search_docs
from record import RUN_CONFIG, LOOKUP_CONFIG, page_reader, push_to_talk
HERE = Path(__file__).parent
RECORD = json.loads((HERE / "tools-run-historical.json").read_text())
RUNS = {run["name"]: run for run in RECORD["runs"]}
CURRENT = json.loads((HERE / "tools-run.json").read_text())
CURRENT_RUNS = {run["name"]: run for run in CURRENT["runs"]}
BEFORE_ACTIVITY = json.loads((HERE / "tools-run-before-activity-signals.json").read_text())
live_harness.PACE = 0
live_tools.SLOW_S = 0.05
logging.disable(logging.CRITICAL)
def converse(name, turns, agent_factory=build_agent, timeout=10, runs=RUNS, run_config=RUN_CONFIG):
model = ReplayLive(model=runs[name]["connections"][0]["model"],
connections=copy.deepcopy(runs[name]["connections"]), opened=[])
async def run():
runner = Runner(app_name="voice_docs", agent=agent_factory(model), session_service=InMemorySessionService())
session = await runner.session_service.create_session(app_name="voice_docs", user_id="dev")
return await talk(runner, session.id, run_config, turns, timeout=timeout)
return asyncio.run(run()), model
def tool_results(model, name):
return [part.function_response.response for _, connection in model.opened for kind, item in connection.received
if kind == "content" for part in item.parts or [] if part.function_response and part.function_response.name == name]
def finished_lines(run):
return [r["output_transcription"]["text"] for r in run["events"]
if "output_transcription" in r and not r.get("partial")]
def calls(run):
return [call["name"] for row in run["events"] for call in row.get("calls", [])]
class LiveTools(unittest.TestCase):
def test_push_to_talk_marks_both_activity_boundaries(self):
async def check():
queue = LiveRequestQueue()
await push_to_talk(bytes(320))(queue)
requests = [await asyncio.wait_for(queue.get(), 0.1) for _ in range(3)]
self.assertIsNotNone(requests[0].activity_start)
self.assertEqual(requests[1].blob.mime_type, "audio/pcm;rate=16000")
self.assertIsNotNone(requests[2].activity_end)
asyncio.run(check())
def test_activity_signals_complete_the_recorded_lookup(self):
failed = BEFORE_ACTIVITY["runs"][0]
self.assertEqual(BEFORE_ACTIVITY["environment"]["model"], "gemini-3.8-live")
self.assertIn("TimeoutError", failed["error"])
self.assertFalse(calls(failed))
current = CURRENT_RUNS["blocking lookup"]
self.assertEqual(CURRENT["environment"]["tools_model"], "gemini-3.8-live")
self.assertNotIn("error", current)
[connection] = current["connections"]
controls = [e.get("mime_type") for e in connection["traffic"] if e.get("sent") == "realtime"]
self.assertIn("ActivityStart", controls)
self.assertIn("ActivityEnd", controls)
self.assertTrue(connection["config"]["realtime_input_config"]["automatic_activity_detection"]["disabled"])
behaviors = {tool["name"]: tool["behavior"] for tool in connection["tools"]}
self.assertEqual(behaviors["search_docs"], "BLOCKING")
_, model = converse("blocking lookup", [push_to_talk(read_pcm(HERE / "lookup.wav"))], runs=CURRENT_RUNS, run_config=LOOKUP_CONFIG)
results = tool_results(model, "search_docs")
self.assertTrue(any("10 minutes" in hit["line"] for result in results for hit in result["hits"]))
def test_lookup_explicitly_declares_blocking_for_current_live_model(self):
lookup = build_agent().tools[0]
self.assertIsInstance(lookup, FunctionTool)
self.assertEqual(lookup.name, "search_docs")
self.assertEqual(lookup.behavior, types.Behavior.BLOCKING)
def test_adk_runs_the_lookup_and_sends_the_result_back(self):
_, model = converse("blocking lookup", [say_audio(read_pcm(HERE / "lookup.wav"))])
[result] = tool_results(model, "search_docs")
self.assertTrue(any("10 minutes" in hit["line"] for hit in result["hits"]))
def test_search_reports_a_miss_instead_of_inventing_a_hit(self):
self.assertEqual(search_docs("kubernetes helm chart")["hits"], [])
def test_async_tools_are_declared_non_blocking(self):
behaviors = {t["name"]: t["behavior"] for t in RUNS["blocking lookup"]["connections"][0]["tools"]}
self.assertEqual(behaviors, {"search_docs": None, "export_page_index": "NON_BLOCKING",
"check_links": "NON_BLOCKING", "stop_streaming": None})
def test_each_streamed_update_goes_back_to_the_model(self):
turns = [say_text("Check the links in the docs pages."), wait_for_turn(), say_text("Never mind, stop the link check.")]
_, model = converse("streaming link check, then stop", turns)
updates = [r["result"] for r in tool_results(model, "check_links") if "result" in r]
self.assertTrue(updates[0].startswith("Checked "), updates)
self.assertEqual(updates[-1], "Done: 3 pages checked.")
def test_the_pending_status_is_spoken_as_its_own_turn(self):
lines = finished_lines(RUNS["streaming link check, then stop"])
self.assertEqual(lines[0].lower(), "async return")
def test_streamed_updates_reach_the_model_but_are_not_narrated(self):
run = RUNS["streaming link check, then stop"]
sent = [e for e in run["connections"][0]["traffic"] if e.get("function_responses") == ["check_links"]]
self.assertGreaterEqual(len(sent), 4) # the pending status, then each yield
before_stop = finished_lines(run)[:2]
self.assertFalse(any("of 3" in line or "Checked" in line for line in before_stop), before_stop)
def test_a_stop_after_the_stream_finished_finds_nothing(self):
run = RUNS["streaming link check, then stop"]
[stop] = [r["response"] for row in run["events"] for r in row.get("responses", []) if r["name"] == "stop_streaming"]
self.assertIn("No active streaming function", stop["status"])
def test_a_raising_tool_ends_the_whole_live_session(self):
with self.assertRaises(FileNotFoundError):
converse("tool raises an exception", [say_text("Read the page deploy.md.")], agent_factory=page_reader)
self.assertTrue(RUNS["tool raises an exception"]["error"].startswith("FileNotFoundError"))
def test_scheduled_result_on_gemini_3_1_is_never_announced(self):
run = RUNS["scheduled export on Gemini 3.1 Flash Live"]
self.assertIn("export_page_index", [r["name"] for row in run["events"] for r in row.get("responses", [])])
self.assertIn("TimeoutError", run["error"])
def test_gemini_2_5_tool_calls_never_reach_adk(self):
self.assertEqual(calls(RUNS["tools on Gemini 2.5 Flash Live"]), [])
raw = {r["model"]: r["messages"] for r in RECORD["raw_tool_call"]}
self.assertEqual(raw[RECORD["environment"]["model"]][0], "tool_call")
self.assertNotIn("turn_complete", raw[RECORD["environment"]["model"]])
if __name__ == "__main__":
unittest.main()How it works
When a function call arrives, ADK finds the tool, runs its callbacks and invokes it. A blocking result goes back before the model continues that call. A streaming tool first returns a pending status, then each yield becomes another response. Scheduled work sends its result when it finishes. These function responses return through the same LiveRequestQueue that receives caller input, while spoken output arrives as separate Event objects.
One InvocationContext spans the run_live call. A tool that sets tool_context.actions.end_invocation ends it, and an unhandled tool exception also reaches the caller. Catch service failures that the agent can explain; let programming errors remain visible to the server.
What’s next
- Run live agents inside a workflow: pass a typed request from intake to answer during one call.
Check your understanding
Read the explanation
The tool reads Markdown and counts link syntax. It does not request destinations or test their availability.
References
Checked October 22, 2026, against the installed Google ADK package and current model documentation.