Skip to Content

Zero to Hero: Building an Autonomous Web Research Agent with Google ADK

Jobber Agent

Zero to Hero: Building an Autonomous Web Research Agent with Google ADK

Introduction

Welcome to the Jobber Agent tutorial! This guide walks through the complete implementation of a web research assistant built on Google's Agent Development Kit (ADK).

The Jobber Agent uses a Planner → Specialist delegation pattern. A master planner_agent receives the user's query, formulates a research plan, then delegates to two specialized sub-agents:

  • search_agent — a Discovery Specialist that uses Google's built-in search grounding to find relevant URLs and snippets.
  • web_reader_agent — a Content Extraction Specialist that loads and parses full web pages using requests + BeautifulSoup.

This separation is not just for cleanliness—it is a required architectural decision. Gemini's API does not allow built-in tools (like google_search) and custom function tools (like load_web_page) in the same request. Splitting them into isolated agents with SingleFlow execution solves this conflict entirely.

Step 1: The Blueprint

Project Structure

We use ADK's standard flat project layout. Each file has a clear responsibility:


/jobber_agent/
    ├── __init__.py         # 🚪 Required package declaration
    ├── agent.py            # 🧠 The Brain: Planner + Search + Reader pipeline
    ├── prompts.py          # 🎭 The Personas: Role definitions for each agent
    ├── tools.py            # 🛠️ Extensible placeholder for custom tools
    ├── ltm.py              # 💾 Long-Term Memory: Persistent user preferences
    ├── logger.py           # 📋 Logging: File + console output for debugging
    ├── .env                # 🔑 API Keys and project configuration
    ├── run_agent.sh        # 🚀 Discovery-optimized Web UI launcher
    └── user_preferences/   # 📁 Directory for user preference files
        └── user_preferences.txt
            
ADK Naming Rule: The project directory must use underscores (jobber_agent), not hyphens. ADK validates agent names as Python identifiers, and hyphens will cause a pydantic ValidationError at startup.
prompts.py

Step 2: Defining Agent Personas

Each agent has a precise, focused prompt. The Planner acts as the coordinator, while the specialists execute narrow tasks. Note the $basic_user_information template variable in the Planner prompt — ADK's string.Template engine injects the user's Long-Term Memory at runtime.


# prompts.py

PLANNER_AGENT_PROMPT = """You are a highly capable AI Assistant specializing
in web research and task planning.
Your goal is to help the user by breaking down complex requests into
actionable steps and delegating execution to your specialized sub-agents.

Current State:
$basic_user_information

Core Responsibilities:
1. Analyze User Request: Understand exactly what the user wants to achieve.
2. Formulate a Plan: Create a logical sequence of steps.
3. Search: Delegate to `search_agent` to find relevant websites or snippets.
4. Read: Delegate to `web_reader_agent` if you need to extract full text
   content of a specifically discovered URL.
5. Synthesize: Once research is complete, provide a comprehensive answer.

Guidelines:
- Be concise.
- If search results are clear enough, avoid unnecessary page loading.
- Use `web_reader_agent` for deep dives or extracting detailed data from
  a specific page.
"""

SEARCH_AGENT_PROMPT = """You are a Discovery Specialist. Your job is to find
the most relevant URLs and snippets on the web.
Use the `google_search` tool to find information. Provide a list of promising
sources or snippets back to the Planner.
"""

WEB_READER_AGENT_PROMPT = """You are a Content Extraction Specialist. Your job
is to read and summarize the full text of a specific web page.
Use the `load_web_page` tool for the URL provided. Extract the key information
requested by the Planner.
"""
            
ltm.py

Step 3: Long-Term Memory

The Jobber Agent remembers user preferences across sessions. Before the Planner starts reasoning, a callback loads any saved preferences from disk and injects them into the session state via $basic_user_information.


# ltm.py
import os
from .logger import logger

# Define local preferences path
USER_PREFERENCES_PATH = os.path.join(
    os.path.dirname(os.path.abspath(__file__)), 'user_preferences'
)
if not os.path.exists(USER_PREFERENCES_PATH):
    os.makedirs(USER_PREFERENCES_PATH)

def get_user_ltm():
    user_preference_file_name = "user_preferences.txt"
    user_preference_file = os.path.join(
        USER_PREFERENCES_PATH, user_preference_file_name
    )
    try:
        if os.path.exists(user_preference_file):
            with open(user_preference_file) as file:
                user_pref = file.read()
            logger.info(f"User preferences loaded from: {user_preference_file}")
            return user_pref
    except Exception as e:
        logger.error(f"Error loading user preferences: {e}")
    return "No previous information available."
            
How it works: Create a file at user_preferences/user_preferences.txt with any context you want the agent to always have — e.g., "User prefers concise bullet-point answers. Location: Morocco.". The Planner prompt's $basic_user_information placeholder is replaced with this content automatically.
logger.py

Step 4: Structured Logging

A clean logging module provides both console output and persistent file logging for debugging agent behavior in production:


# logger.py
import os
import logging
from typing import Union

# Create a logs directory if it doesn't exist
log_directory = os.path.join(
    os.path.dirname(os.path.abspath(__file__)), "logs"
)
os.makedirs(log_directory, exist_ok=True)

# Configure the root logger
logging.basicConfig(
    level=logging.INFO,
    format="[%(asctime)s] %(levelname)s {%(filename)s:%(lineno)d} - %(message)s",
)

logger = logging.getLogger(__name__)
logger.addHandler(
    logging.FileHandler(os.path.join(log_directory, "jobber_agent.log"))
)

def set_log_level(level: Union[str, int]) -> None:
    if isinstance(level, str):
        level = level.upper()
        numeric_level = getattr(logging, level, None)
        if not isinstance(numeric_level, int):
            raise ValueError(f"Invalid log level: {level}")
        logger.setLevel(numeric_level)
    else:
        logger.setLevel(level)
            
agent.py

Step 5: The Agent Pipeline

This is the heart of the Jobber Agent. It wires together the Planner, the two specialists, the LTM callback, and the orchestrator. Pay close attention to the disallow_transfer_to_parent and disallow_transfer_to_peers flags — they are critical for avoiding the Gemini tool conflict.


# agent.py
import os
import datetime
from string import Template
from dotenv import load_dotenv

# Load environment variables
current_dir = os.path.dirname(os.path.abspath(__file__))
load_dotenv(os.path.join(current_dir, ".env"))

from google.adk.agents import LlmAgent, SequentialAgent
from google.adk.agents.callback_context import CallbackContext
from google.adk.tools import google_search
from google.adk.tools.load_web_page import load_web_page

from .prompts import (
    PLANNER_AGENT_PROMPT,
    SEARCH_AGENT_PROMPT,
    WEB_READER_AGENT_PROMPT,
)

# --- CALLBACKS ---

async def before_planner_callback(
    ctx: CallbackContext = None, **kwargs
) -> None:
    """Initializes LTM if not already present."""
    if ctx is None:
        ctx = kwargs.get('callback_context')

    if ctx and "ltm" not in ctx.state:
        try:
            from .ltm import get_user_ltm
            ctx.state["ltm"] = get_user_ltm()
        except Exception:
            ctx.state["ltm"] = "No previous information available."

# --- SPECIALIZED AGENTS ---

search_agent = LlmAgent(
    name="search_agent",
    model="gemini-2.0-flash",
    description="Finds relevant websites and snippets using Google Search.",
    instruction=SEARCH_AGENT_PROMPT,
    tools=[google_search],               # ONLY built-in tool
    disallow_transfer_to_parent=True,     # Forces SingleFlow
    disallow_transfer_to_peers=True,
)

web_reader_agent = LlmAgent(
    name="web_reader_agent",
    model="gemini-2.0-flash",
    description="Loads and reads the full text content of a specific URL.",
    instruction=WEB_READER_AGENT_PROMPT,
    tools=[load_web_page],               # ONLY function tool
    disallow_transfer_to_parent=True,
    disallow_transfer_to_peers=True,
)

# --- PLANNER AGENT ---

def get_planner_instruction(ctx: CallbackContext) -> str:
    today = datetime.datetime.now()
    date_info = (
        f"\nToday's date is: {today.strftime('%d/%m/%Y')}"
        f"\nCurrent weekday is: {today.strftime('%A')}"
    )
    ltm_data = ctx.state.get("ltm", "No previous information available.")
    return (
        Template(PLANNER_AGENT_PROMPT)
        .safe_substitute(basic_user_information=ltm_data) + date_info
    )

planner_agent = LlmAgent(
    name="planner_agent",
    model="gemini-2.0-flash",
    description="Main planner that coordinates research and tasks.",
    instruction=get_planner_instruction,
    sub_agents=[search_agent, web_reader_agent],
    before_agent_callback=before_planner_callback,
)

# --- THE ORCHESTRATOR ---
root_agent = SequentialAgent(
    name="jobber_agent",
    description="An AI agent for automating research and web-based tasks.",
    sub_agents=[planner_agent],
)
            
Critical ADK Insight: By default, ADK's LlmAgent uses AutoFlow, which injects a transfer_to_agent function tool into every LLM request. This conflicts with Gemini's built-in google_search grounding, producing a 400 INVALID_ARGUMENT error. Setting both disallow_transfer_to_parent=True and disallow_transfer_to_peers=True switches the agent to SingleFlow, which avoids injecting that tool entirely.
Callback Signature: ADK may pass the context as a keyword argument callback_context instead of a positional argument. The **kwargs pattern ensures compatibility across ADK versions.
.env

Step 6: Environment Configuration

The .env file configures the Gemini API key and project settings. Set GOOGLE_GENAI_USE_VERTEXAI=0 for AI Studio (dev), or 1 for Vertex AI (production).


# .env
GOOGLE_GENAI_USE_VERTEXAI=0
GOOGLE_API_KEY=YOUR_GEMINI_API_KEY_HERE
GOOGLE_CLOUD_PROJECT=your-gcp-project-id
GOOGLE_CLOUD_LOCATION=global
            
run_agent.sh

Step 7: The Web UI Launcher

ADK discovers agents by scanning subdirectories from the current working directory. This means we must run adk web . from the parent directory of jobber_agent/, not from inside it. The launch script handles this automatically:


#!/bin/bash
# A simple script to launch the Jobber Agent
# ADK discovers agents by scanning subdirectories from the CWD,
# so we must run from the PARENT of the agent folder.

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PARENT_DIR="$(dirname "$SCRIPT_DIR")"
AGENT_NAME="$(basename "$SCRIPT_DIR")"

echo "Launching $AGENT_NAME ADK Agent on Web UI..."
echo "  Working directory: $PARENT_DIR"
echo "  Agent: $AGENT_NAME"
echo ""

cd "$PARENT_DIR" && adk web .
            

To launch the agent:


cd /path/to/jobber_agent
./run_agent.sh
# Server starts at http://127.0.0.1:8000
            
Demo

Final Result

Below is the Jobber Agent in action via the ADK Web UI. The Planner receives the user's query, delegates to the search_agent for discovery, then synthesizes a grounded, factual response — all visible in the execution trace:

in AI
Zero to Hero: Building a Multi-Tier Cyber Security Operations Center with Google ADK