Sebastian Gomez
Callbacks and guardrails for reliable agents
Lesson notebook: Lesson_4.ipynb
Agents are powerful and, at the same time, non deterministic. In this lesson we add programmatic control so your agent behaves predictably: we tune the instructions and use callbacks as guardrails that filter domains, enrich responses, and leave a trail of traceability. By the end you will have a production ready agent that combines everything from the previous lessons with effective controls.
Versions we use here. This lesson assumes google-adk 1.0 or higher and the Gemini 2.5 model family (gemini-2.5-flash). Two details matter for the code to run as is: passing a list to before_tool_callback and after_tool_callback requires a recent ADK version, and google_search as a built in tool requires a Gemini 2 model or higher. If you are coming from older installs, update with pip install -U google-adk.
Overview
- Reuse the previous tools:
get_financial_contextandsave_news_to_markdown. - Understand the extension points: before/after agent, tool, and model.
- Implement two callbacks: source filtering (before tool) and response enrichment (after tool).
- Update the instructions so the agent is aware of the callbacks.
- Test in
adk web, validate the process logs, and shut services down.
4.1 Environment setup
Unlike the voice lessons, this agent is synchronous and orchestrates text tools, so we do not need a Live model. We will use a standard Flash model, which is the recommended choice for text and tool calling flows.
adk create app5 --model gemini-2.5-flash --api_key $GOOGLE_API_KEYLive only when you need it. The *-live-* models are optimized for bidirectional audio streaming. They made sense in the voice lessons; here, for a text agent that calls tools, gemini-2.5-flash is a better and cheaper fit. Reserve the Live model for when you actually use the Live API.
Credentials: define a .env in app5/ (AI Studio: GOOGLE_API_KEY; Vertex AI: GOOGLE_GENAI_USE_VERTEXAI=TRUE, GOOGLE_CLOUD_PROJECT, GOOGLE_CLOUD_LOCATION). If you use load_env() in notebooks, treat it as a helper; when running with adk web, ADK reads the .env for you.
4.2 Reuse tools from previous lessons
We copy get_financial_context (lessons 2 and 3) and save_news_to_markdown (lesson 3) into app5/agent.py.
import pathlib
from typing import Dict
def save_news_to_markdown(filename: str, content: str) -> Dict[str, str]:
if not filename.endswith(".md"):
filename += ".md"
file_path = pathlib.Path.cwd() / filename
file_path.write_text(content, encoding="utf-8")
return {"status": "success", "message": f"Saved to {file_path.resolve()}"}4.3 Callbacks in ADK
ADK offers hooks at several points of the lifecycle:
before_agent_callback/after_agent_callbackbefore_tool_callback/after_tool_callbackbefore_model_callback/after_model_callback
### Callback 1: source filtering (before tool)
It blocks searches toward unwanted domains and responds with descriptive errors.
BLOCKED_DOMAINS = [
"wikipedia.org",
"reddit.com",
"youtube.com",
"medium.com",
"investopedia.com",
"quora.com",
]
def filter_news_sources_callback(tool, args, tool_context):
if tool.name == "google_search":
query = args.get("query", "").lower()
for domain in BLOCKED_DOMAINS:
if f"site:{domain}" in query or domain.split(".")[0] in query:
return {
"error": "blocked_source",
"reason": f"Searches targeting {domain} are not allowed. Use professional news sources.",
}
return None### Callback 2: response enrichment (after tool)
It extracts domains from the results, keeps a process_log in tool_context.state, and injects it into the output. Notice the imports at the top of the block: we need re and urlparse, which were missing in the original version.
import re
from urllib.parse import urlparse
from google.adk.tools import ToolContext
def initialize_process_log(tool_context: ToolContext):
if "process_log" not in tool_context.state:
tool_context.state["process_log"] = []
def inject_process_log_after_search(tool, args, tool_context, tool_response):
if tool.name != "google_search":
return tool_response
# Normalize the response (str or dict)
if isinstance(tool_response, dict):
raw = tool_response.get("search_results") or tool_response.get("results") or ""
else:
raw = tool_response
if isinstance(raw, str) and raw:
urls = re.findall(r"https?://[^\s/]+", raw)
unique_domains = sorted(list({urlparse(url).netloc for url in urls}))
if unique_domains:
sourcing_log = f"Action: Sourced news from: {', '.join(unique_domains)}."
tool_context.state["process_log"] = [sourcing_log] + tool_context.state.get("process_log", [])
# Return the enriched structure
return {
"search_results": raw if isinstance(raw, str) else str(raw),
"process_log": tool_context.state.get("process_log", []),
}So that initialize_process_log does not sit there as dead code, we wire it into before_agent_callback. That way the state is created before the first search and the after tool callback only enriches it.
4.4 Modify the agent to use callbacks
Here an important design detail shows up. ADK restricts combining a built in tool such as google_search with custom tools on the same LlmAgent. If you put them all together in tools=[...], you will most likely get an error when building the agent.
The recommended pattern: isolate `google_search` in a sub agent. We wrap google_search in its own LlmAgent and expose it to the main agent through AgentTool. That way the coordinator keeps its custom tools and delegates the search to the sub agent. The search callbacks live in the sub agent, which is the one that actually invokes google_search.
AgentTool have changed across ADK versions. Before publishing, confirm against the current ADK docs whether your version already allows mixing them directly; if so, you could simplify and drop the sub agent.
from google.adk.agents import LlmAgent
from google.adk.tools import google_search
from google.adk.tools.agent_tool import AgentTool
# Dedicated search sub agent: this is where the google_search callbacks live.
search_agent = LlmAgent(
name="news_search_agent",
model="gemini-2.5-flash",
tools=[google_search],
instruction="Search for recent AI news using google_search and return the results.",
before_tool_callback=[filter_news_sources_callback],
after_tool_callback=[inject_process_log_after_search],
)
root_agent = LlmAgent(
name="ai_news_research_coordinator",
model="gemini-2.5-flash",
tools=[
AgentTool(agent=search_agent),
get_financial_context,
save_news_to_markdown,
],
before_agent_callback=[lambda ctx: initialize_process_log(ctx)],
instruction="""
Your sole purpose is to prepare an AI news report with financial context.
Execution plan:
1) Search for 5 recent news items through the search sub agent.
2) Extract tickers and call `get_financial_context`.
3) Format everything into Markdown following the required schema.
4) Save it to `ai_research_report.md` with `save_news_to_markdown`.
Understanding callbacks and modified outputs:
- The search can return { search_results: str, process_log: list[str] }.
- Use `process_log` to include sources and actions in the final report.
Crucial operating rule: do not show intermediate content. Only confirm the start and the final delivery.
""",
)Notice that we pass the callbacks as a list. That list form is a recent ADK feature; if your install is older and only accepts a single callable, update ADK or pass the function directly without the list.
4.5 End to end testing
Launch the UI from the parent folder with adk web and select "app5", or point straight at the folder:
adk web --port 8000 app5On Windows, use --no-reload if needed. Stop the server with Ctrl-C.
Try a message like "Find the latest AI news". The agent confirms the start, works silently, and ends with a final message confirming ai_research_report.md. Close the processes when you are done.
4.6 View the report
Verify that the Markdown includes headlines, tickers, a financial metric, and a process_log with the source domains.
from IPython.display import Markdown, display
with open("ai_research_report.md", "r", encoding="utf-8") as f:
display(Markdown(f.read()))Best practices and next steps
- Document your policies: which domains you block and why, and test edge cases.
- Include the
process_login the final artifacts so you have traceability. - Centralize common callbacks (observability, input and output validation, prompt injection mitigation). When a policy must apply globally to every agent, consider moving it to an ADK Plugin instead of repeating callbacks.
- If you are aiming for production, consider moving the report storage to a bucket or a database.
Note on Google Search. The google_search tool works with Gemini 2 models or higher. If the model returns "Search suggestions", show them in your UI, since that is part of the Grounding policy. More information in the ADK built in tools docs.
>
google_search moves fast. Confirm it for Gemini 2.5 in the official docs before publishing.
Your agent now combines silent research with programmatic guardrails and traceability. From here you can scale to multi agent systems and even more structured responses.
Suggested exercises
- Add a third domain to
BLOCKED_DOMAINSand verify inadk webthat the filter returns the descriptive error when a query targets it. - Extend
initialize_process_logso it also records the process start time, and verify it shows up in the finalprocess_log. - Move
filter_news_sources_callbackinto a global ADK Plugin and compare the experience against registering it callback by callback.
3-point summary
- ADK before/after callbacks are your programmatic guardrails: they filter sources, enrich responses, and leave traceability without touching the model logic.
- To combine
google_searchwith custom tools, isolate the search in a sub agent and expose it withAgentTool; usegemini-2.5-flashfor this text flow and leave the Live models for voice. - Passing callbacks as a list and keeping a
process_logintool_context.stategives you production ready observability; centralize global policies in Plugins.
Resources
- Callbacks: types of callbacks
- Plugins: Plugins docs
- Built in tools: built in tools
- Previous lesson: ADK lesson 3, build a background research agent
- Next lesson: ADK lesson 5, structured responses with schemas and validation
This content is based on the course "Building Live Voice Agents with Google's ADK!" by DeepLearning.AI (available at learn.deeplearning.ai). This blog aims to bring ADK material to the Spanish speaking community.
I hope this post is useful to you and that you can apply it to a project you have in mind. Leave me a comment if it helped, if you want to add an opinion, or if you have any questions, and remember that if you liked it you can also share it using the social links below. Good luck building reliable agents.
Sebastian Gomez
Creador de contenido principalmente acerca de tecnología.