DEV Community

Cover image for Give Your AI Agents Deep Understanding — Coding the Multi-Agent ADK Solution
Darren "Dazbo" Lester for Google Developer Experts

Posted on • Originally published at Medium on

Give Your AI Agents Deep Understanding — Coding the Multi-Agent ADK Solution

Welcome to the Coding Phase!

In this part I’ll show the process of actually coding the multi-agent application using the Google Agent Development Kit (ADK).

Orientation

In the first part I covered:

  1. Our primary goal: to give my coding agent (I use Gemini CLI) in-depth and up-to-date knowledge about the Google Agent Development Kit. (But it could be any repo or folder.)
  2. How llms.txt is a great standard for allowing LLMs (like Gemini) to understand the structure of a folder or repo, and to help the LLM to immediately lookup the most appropriate documents to respond to your queries.
  3. How we can use the free and open-source MCP LLMS-TXT Doc Server to provide an off-the-shelf MCP server to guide the LLM to read an llms.txt, and use the links inside it it to find the most appropriate material.
  4. How easy it is to integrate such an MCP server into your client tool, like Gemini CLI.
  5. How the ADK-Docs repo contains two sort-of llms files: llms.txt and llms-full.txt. But they do not align to the llms.txt standard, and they are not well-suited for our goal.

In the second part I covered:

  1. Creating the multi-agent solution design
  2. Setting up your project, development environment and helper classes

What I’ll Cover in This Part

Here I’ll go through:

  • Coding the agents and their tools.
  • Using a Jupyter notebook for experimentation, testing and iteration.
  • Leveraging the ADK Web UI for testing, debugging and tracing.
  • Building the frontend command line interface.
  • Using our newly generated llms.txt with an MCP server to guide Gemin CLI.
  • The challenges faced and how I solved them, such as output schema and rate limiting.
  • Summary of ADK tips and lessons learned.

Let’s Get Coding!

As a quick reminder, this is what we’re building:

Multi-Agent Solution Design

Create the Coordinator Agent

Let’s start with the brains of the operation. The main generate_llms_coordinator agent will live in the llms_gen_agent folder. The first iteration looks something like this:

"""
This module defines the main agent for the LLMS-Generator application.
"""
from google.adk.agents import Agent
from google.adk.tools.agent_tool import AgentTool
from google.genai.types import GenerateContentConfig

from .config import setup_config
from .sub_agents.doc_summariser import document_summariser_agent
from .tools import discover_files, generate_llms_txt

config = setup_config()

# Agent is an alias for LlmAgent
# It is non-deterministic and decides what tools to use,
# or what other agents to delegate to
generate_llms_coordinator = Agent(
    name="generate_llms_coordinator",
    description="An agent that generates a llms.txt file for a given repository. Coordinates overall process.",
    model=config.model,
    instruction="""You are an expert in analyzing code repositories and generating `llms.txt` files.
Your goal is to create a comprehensive and accurate `llms.txt` file that will help other LLMs understand the repository.

When the user asks you to generate the file, you should ask for the absolute path to the repository/folder, and optionally an output path.

Here's the detailed process you should follow:

1. **Discover Files** : Use the `discover_files` tool with the provided `repo_path` to get a list of all relevant files paths, in the return value `files`.
2. **Check Files List** : Check you received a success response and a list of files. If not, you should provide an appropriate response to the user and STOP HERE.
3. **Summarize Files** : Delegate to the `document_summariser_agent` Agent Tool.
   **CRITICAL: This tool MUST be called with NO arguments.**
   The `document_summariser_agent` will read the list of files from the session state under the key 'files' (which was populated by the `discover_files` tool).
   The `document_summariser_agent` will then return the full set of summaries as JSON with a single key `summaries` that contains a dictionary of all the path:summary pairs.
   **Example of correct call:** `document_summariser_agent()`
4. **Check Summary Response** : you should have received a JSON response containing the summaries. This contains all the files originally discovered, with each mapped to a summary.
   If so, continue. If not, you should provide an appropriate response to the user and STOP HERE.
5. **Generate `llms.txt`** : Call the `generate_llms_txt` tool. Provide `repo_path` as an argument.
   If the user provided an output path, provide it as the `output_path` argument. The tool will determine other required values from session state.
6. **Response** Finally, respond to the user confirming whether the `llms.txt` creation was successful. State the path where the file has been created, which is stored in session state key `llms_txt_path`.
""",
    tools=[
        discover_files,  # automatically wrapped as FunctionTool
        generate_llms_txt, # automatically wrapped as FunctionTool
        AgentTool(agent=document_summariser_agent)
    ],
    generate_content_config=GenerateContentConfig(
        temperature=0.1,
        top_p=1,
        max_output_tokens=60000
    )
)

root_agent = generate_llms_coordinator
Enter fullscreen mode Exit fullscreen mode

Let’s understand some important details about this code:

  • We’re creating a coordinator agent named generate_llms_coordinator. It’s just a bog-standard ADK LlmAgent that has been empowered with another agent and tools, and contains very specific instructions regarding how and when to use those tools.
  • The description describes the overall purpose of this coordinator.
  • The model is pulled from our config — it will be set to gemini-2.5-flash.
  • The instruction is set to a prompt that explicitly states which tools to use, and which agents to delegate to. We should not provide any instructions for how those tools and agents will do their own jobs; that’s their responsibility.
  • The tools parameter is where we provide the list of tools and agents that are referenced in the prompt. Note that I’ve document_summariser_agent with AgentTool, so that we can use it as a tool.

Create the “Discover Files” Tool

The first part of our orchestrator prompt was:

1. Discover Files: Use the discover_files tool with the provided repo_path to get a list of all relevant files paths, in the return value files.

Remember that to interact with the external environment an agent needs to use tools. The first tool we’ll give our Coordinator agent is the ability to trawl through a folder or repo, and identify the paths of all relevant files. We’ll call this discover_files and we’ll put it in our tools.py file. It starts out looking like this:

"""
This module provides a collection of tools for the LLMS-Generator agent.
The tools are designed to facilitate the discovery of files within a given repository,
read their contents, and generate a structured `llms.txt` sitemap file based on the findings.

Key functionalities include:
- `discover_files`: Scans a repository to find relevant files (e.g. markdown and python files), excluding common temporary or git-related directories.
- `generate_llms_txt`: Constructs the `llms.txt` Markdown file, organizing discovered files into sections with summaries.
"""
import os
from google.adk.tools import ToolContext
from .config import logger

def discover_files(repo_path: str, tool_context: ToolContext) -> dict:
    """Discovers all relevant files in the repository and returns a list of file paths.

    Args:
        repo_path: The absolute path to the repository to scan.

    Returns:
        A dictionary with "status" (success/failure) and "files" (a list of file paths).
    """
    logger.debug("Entering tool: discover_files with repo_path: %s", repo_path)

    excluded_dirs = {'.git', '.github', 'overrides', '.venv', 'node_modules', '__pycache__', '.pytest_cache'}
    excluded_files = {'__init__'}
    included_extensions = {'.md', '.py'}

    directory_map: dict[str, list[str]] = {}

    try:
        for root, subdirs, files in os.walk(repo_path):
            # Modify subdirs in place so that os.walk() sees changes directly
            subdirs[:] = [d for d in subdirs if d not in excluded_dirs]

            for file in files:
                if (any(file.endswith(ext) for ext in included_extensions) and
                    not any(file.startswith(ext) for ext in excluded_files)):

                    file_path = os.path.join(root, file)
                    directory = os.path.dirname(file_path)

                    if directory not in directory_map:
                        directory_map[directory] = []
                    directory_map[directory].append(file_path)

        all_dirs = list(directory_map.keys())
        tool_context.state["dirs"] = all_dirs  # directories only
        logger.debug("Dirs\n:" + "\n".join([str(dir) for dir in all_dirs]))

        # Create a single list of all the files
        all_files = [file for files_list in directory_map.values() for file in files_list]
        tool_context.state["files"] = all_files
        logger.debug("Files\n:" + "\n".join([str(file) for file in all_files]))

        logger.debug("Exiting discover_files.")
        return {"status": "success", "files": all_files}

    except Exception as e:
        logger.error("Error in discover_files: %s", e)
        return {"status": "failure", "files": []}
Enter fullscreen mode Exit fullscreen mode

This tool is simply a user-defined Python function. The function itself isn’t particularly complicated. We’re just using os.walk() to recursively trawl through the directories and files in our folder. Note the use of subdirs[:] which allows us to perform an in-place replacement of the subdirs variable, thus allowing us to exclude any subdirectories that are in our excluded_dirs list. (Later we can parameterise the excluded_dirs. But for now, I’ve hardcoded.)

The end results of the function are:

  • That we have created a Python dictionary, mapping each file to the directory it lives in.
  • That we have attached the all_dirs list to our session state, with the key name dirs. Note that session state is made available to the function through the ToolContext object passed in as an argument.
  • That we have also attached the all_files list to our session state, with the key files.

Note that a session represents a single ongoing conversation between a user and the agent system. The session state is like a temporary working storage area associated with the session. And because this session (and its state) is shared amongst all agents and tools in the application, we can use this for agent-to-agent and agent-to-tool interactions.

Document Summariser Agent

Now that our list of directories and files are stored in our session, we can move on to this phase of the orchestration agent:

  1. Summarize Files: Delegate to the document_summariser_agent Agent Tool. CRITICAL: This tool MUST be called with NO arguments. The document_summariser_agent will read the list of files from the session state (which was populated by the discover_files tool). The document_summariser_agent will then return the full set of summaries as JSON with a single key summaries that contains a dictionary of all the path:summary pairs. Example of correct call: document_summariser_agent()

Note that I’ve had to be VERY explicit with my prompt in the orchestrator. The document summariser agent will retrieve the files list from the session state; it does NOT expect this list of files to be passed from the orchestrator. Without this this explicit command to call the AgentTool “with NO arguments” I found that the orchestrator sometimes tried to pass in the files as a list to the AgentTool. This would cause the application to fail.

Now let’s have a look at src/llms_gen_agent/sub_agents/doc_summariser/agent.py:

"""
Defines a sequential agent responsible for summarizing documents.

This module contains the `document_summariser_agent`, a `SequentialAgent` that orchestrates
a two-step process to read and summarize a collection of files.

The process is as follows:
1. **File Reading:** The `file_reader_agent` reads the content of specified files, storing the content in the session state.
2. **Content Summarization:** The `content_summariser_agent` takes the collected file content and performs two key tasks:
   - It generates a concise summary for each individual file.
   - It generates a higher-level summary for the entire project based on the content of all files.

The final output is a single JSON object containing both the individual file summaries and the overall project summary.
"""
from google.adk.agents import Agent, SequentialAgent
from google.adk.models.llm_response import LlmResponse
from google.genai.types import GenerateContentConfig, Part

from llms_gen_agent.config import setup_config, logger
from llms_gen_agent.schema_types import DocumentSummariesOutput
from llms_gen_agent.tools import read_files

config = setup_config()

file_reader_agent = Agent(
    name="file_reader_agent",
    description="An agent that reads the content of multiple files and stores them in session state.",
    model=config.model,
    instruction="""You are a specialist in reading files.
Your job is to run the `read_files`, which will read a list of files in your session state, and store their contents.

IMPORTANT: you should NOT pass any arguments to the `read_files` tool. It will retrieve its data from session state.
""",
    tools=[
        read_files
    ]
)

content_summariser_prompt = """You are an expert summariser. You will summarise the contents of multiple files, and then you will summarise the overall project.

You will do this work in two phase.

# Phase 1: File Summarisation

- Your task is to summarize EACH individual file's content in three sentences or fewer.
- Do NOT start summaries with text like "This document is about" or "This document provides". Just immediately describe the content.
  E.g. Rather than this: "This document explains how to configure streaming behavior..."
  Say this: "Explains how to configure streaming behavior..."
- If you cannot generate a meaningful summary for a file, use 'No meaningful summary available.' as its summary.
- Aggregate ALL these individual summaries into a single JSON object.

# Phase 2: Project Summarisation

- After summarizing all the files, you MUST also provide an overall project summary, in no more than three paragraphs.
- The project summary should be a high-level overview of the repository/folder, based on the content of the files.
- Focus on the content that is helpful for understanding the purpose of the project and the core components.
- The project summary MUST be stored in the same output JSON object with the key 'project'. This is CRITICAL for the overall understanding of the repository.

# Output Format

- The JSON object MUST have a single top-level key called 'summaries', which contains a dictionary.
- The dictionary contains all the summaries as key:value pairs.
- For the file summaries: the dictionary keys are the original file paths and values are their respective summaries.
- For the project summary: the key is `project`. THIS KEY MUST BE PRESENT. The value is the project summary.

- Example:
{{
  "summaries": {{
    "/path/to/file1.md":"Summary of file 1.",
    "/path/to/file2.md":"Summary of file 2.",
    "/path/to/file3.py":"Summary of python file."
    "project":"Summary of the project."
  }}
}}

IMPORTANT: Your final response MUST contain ONLY this JSON object. DO NOT include any other text, explanations, or markdown code block delimiters.

Now I will provide you with the contents of multiple files. Note that each file has a unique path and associated content.

**FILE CONTENTS START:**
{files_content}
---
**FILE CONTENTS END:**

Now return the JSON object.
"""

content_summariser_agent = Agent(
    name="content_summarizer_agent",
    description="An agent that summarizes collected file contents and aggregates them.",
    model=config.model,
    instruction=content_summariser_prompt,
    generate_content_config=GenerateContentConfig(
        temperature=0.5,
        top_p=1,
        max_output_tokens=64000
    ),
    output_schema=DocumentSummariesOutput, # This is the final output schema
    output_key="doc_summaries" # json with top level called 'summaries'
)

document_summariser_agent = SequentialAgent(
    name="document_summariser_agent",
    description="A sequential agent that first reads file contents and then summarizes them.",
    sub_agents=[
        file_reader_agent,
        content_summariser_agent
    ]
)
Enter fullscreen mode Exit fullscreen mode

The first thing to note is that the document_summariser_agent is actually a SequentialAgent, which is a type of Workflow Agent. In the ADK, Workflow Agents are specialised agents intended purely for orchestrating the flow of sub-agents. This allows our sub-agents to be called in a deterministic way. And it turns out that this is essential!

When I first implemented this sub-agent, I didn’t use a SequentialAgent. Instead I just gave the document_summariser_agent a prompt that told it to first read all the files, and then to summarise them all. But this didn’t work reliably.

Now, as a SequentialAgent it must first run the file_reader_agent and then the content_summariser_agent. Here the file_reader_agent is a wrapper for another tool. It’s a function called read_files() which takes a list of file paths, reads all those files, and stores the content of each file in a session key called files_content.

The value of this key is itself a dictionary, with one item per file. For each file, the key is the file path, and the value is the contents itself.

Note that the read_files() function does not take any paramters, other than the ToolContext which provides it with access to the session state. As before, this function will instead retrieve its input data from the session state.

def read_files(tool_context: ToolContext) -> dict:
    """Reads the content of files and stores it in the tool context.

    This function retrieves a list of file paths from the `files` key in the `tool_context.state`.
    It then iterates through this list, reads the content of each file, and stores it in a
    dictionary under the `files_content` key in the `tool_context.state`. The file path
    serves as the key for its content.

    It avoids re-reading files by checking if the file path already exists in the `files_content` dictionary.

    Returns:
        A dictionary with a "status" key indicating the outcome ("success").
    """
    logger.debug("Executing read_files")
    config = setup_config() # dynamically load config

    file_paths = tool_context.state.get("files", [])
    logger.debug(f"Got {len(file_paths)} files")

    # Implement max files constraint
    if config.max_files_to_process > 0:
        logger.info(f"Limiting to {config.max_files_to_process} files")
        file_paths = file_paths[:config.max_files_to_process]

    # Initialise our session state key
    tool_context.state["files_content"] = {}
    response = {"status": "success"}

    for file_path in file_paths:
        if file_path not in tool_context.state["files_content"]:
            try:
                logger.debug(f"Reading file: {file_path}")
                with open(file_path) as f:
                    content = f.read()
                    logger.debug(f"Read content: {content[:80]}...")
                    tool_context.state["files_content"][file_path] = content
            except (FileNotFoundError, PermissionError, UnicodeDecodeError) as e:
                logger.warning("Could not read file %s: %s", file_path, e)
                # Store an error message so the summarizer knows it failed
                tool_context.state["files_content"][file_path] = f"Error: Could not read file. Reason: {e}"
                response = {"status": "warnings"}
            except Exception as e:
                logger.error("An unexpected error occurred while reading %s: %s", file_path, e)
                tool_context.state["files_content"][file_path] = f"Error: An unexpected error occurred. Reason: {e}"
                response = {"status": "warnings"}

    return response
Enter fullscreen mode Exit fullscreen mode

Now that we’ve read the files and stored their contents, we can move on to the actual summarisation step. This is done by the content_summarisation_agent. There’s a few interesting things to observe about the prompt I’ve given this agent:

  1. I’ve told it to perform summarisation of the files as one phase, and then summarisation of the repo/folder as a second phase.
  2. I found that the summaries would often follow the pattern “This document is about [whatever].” I told the agent to NOT include any kind of “This document is about” preamble, but the agent carried on producing summaries in this way, regardless. So I added examples of what to say and what not to say, and this fixed the issue. (One-shot prompting FTW.)
  3. I’ve had to include an example (one-shot prompting, again) of what the output JSON should look like. Without this, the output is unreliable.
  4. I then needed to provide my agent with all the {file:content} pairs. We can do this directly in the prompt, using a mechanism called key templating. It works like this: if we have a session state key called “foo” (i.e. context.state["foo"]), then we can pass this directly into our prompt by wrapping “foo” in curly braces. So your prompt looks something like this: “Do stuff with {foo}”.

The agent definition itself also includes an output_key property. By setting this, we tell the agent to store the its return value in the session state, with the name specified. So my agent stores its output as a JSON object in the session state, with a key called doc_summaries.

I was still finding that the output format was a little unpredictable, so I additionally set the output_schema property of the agent. I’ve set it to use this custom class:

class DocumentSummariesOutput(BaseModel):
    summaries: dict[str, str] = Field(
        description="A dictionary where keys are file paths and values are their summaries."
    )
Enter fullscreen mode Exit fullscreen mode

I’ve added this definition to a new Python module called schema_types.py. By creating this custom class that extends Pydantic’s BaseModel, I can force the agent to always create output in the correct JSON format, where the JSON object itself contains a single dictionary.

This works well BUT… The agent sometimes doesn’t just return the JSON object. Sometimes it adds some preamble before returning the object, or wraps the JSON object in some sort of markup. If it does this, then the next part of the chain fails.

To mitigate this problem, I then added an After-Agent-Callback called clean_json_callback:

def clean_json_callback(
    callback_context: CallbackContext,
    llm_response: LlmResponse
) -> LlmResponse | None:
    """
    Strips markdown code block delimiters (```

json,

 ```) from the LLM's text response.
    This callback runs after the model generates content but before output_schema validation.
    """
    logger.debug("--- Callback: clean_json_callback running for agent: %s ---", callback_context.agent_name)

    if llm_response.content and llm_response.content.parts:
        # Assuming the response is text in the first part
        if llm_response.content.parts[0].text:
            original_text = llm_response.content.parts[0].text
            logger.debug(f"--- Callback: Original LLM response text (first 100 chars): '{original_text[:100]}...'")

            # re.DOTALL allows . to match newlines, \s* matches any whitespace (including newlines)
            # (.*?) is a non-greedy match for the content inside the code block
            match = re.search(r"(?:\w*\s*)?(.*?)\s*", original_text, flags=re.DOTALL)

            if match:
                cleaned_text = match.group(1).strip()
                logger.debug(f"--- Callback: Stripped markdown. Cleaned text (first 100 chars): '{cleaned_text[:100]}...'")

                # Create a new LlmResponse with the cleaned content
                # Use .model_copy(deep=True) to ensure you're not trying to modify the original immutable object directly
                new_content = llm_response.content.model_copy(deep=True)
                if new_content.parts and isinstance(new_content.parts[0], Part):
                    new_content.parts[0].text = cleaned_text
                    return LlmResponse(content=new_content)
                else:
                    logger.debug("--- Callback: Error: new_content.parts[0] is not a valid Part object after copy. ---")
                    return llm_response
            else:
                logger.debug("--- Callback: No markdown code block found. Returning original response. ---")
                return llm_response

    return llm_response # Return the original response if no changes or not applicable
Enter fullscreen mode Exit fullscreen mode

After-agent-callbacks do exactly what you expect: they run immediately after an agent has returned a response. This is really useful as it gives us an opportunity to tidy-up the response object. In this case, my function is just stripping out any preamble or markup around the JSON.

Generate the llms.txt File

Now we’ve got our cleaned JSON output we’re ready to return control back to our orchestrator agent, which in turn calls the final tool:

5. Generate llms.txt : Call the generate_llms_txt tool. Provide repo_path as an argument. If the user provided an output path, provide it as the output_path argument. The tool will determine other required values from session state.

I wont’ go through the generate_llms_txt() function in much detail, but I’ll cover off a few points that are useful, in the context of learning ADK:

def generate_llms_txt(repo_path: str, tool_context: ToolContext, output_path: str = "") -> dict:
    """Generates a comprehensive llms.txt sitemap file for a given repository.

    This function orchestrates the creation of an AI/LLM-friendly Markdown file (`llms.txt`) that
    provides a structured overview of the repository's contents. It includes a project summary,
    and organizes files into sections based on their directory structure, with a configurable
    maximum section depth.

    For each file, it generates a Markdown link with its summary. If a summary is not available,
    "No summary" is used as a placeholder. Links are generated as GitHub URLs if the repository
    is detected as a Git repository, otherwise, relative local paths are used.

    Args:
        repo_path: The absolute path to the root of the repository to scan.
        output_path: Optional. The absolute path to save the llms.txt file. If not provided,
            it will be saved in a `temp` directory in the current working directory.

    Other required data is retrieved from tool_context.

    Returns:
        A dictionary with:
        - "status": "success" if the file was generated successfully.
        - "llms_txt_path": The absolute path to the generated llms.txt file.
    """
    logger.debug("Entering generate_llms_txt for repo_path: %s", repo_path)

    dirs = tool_context.state.get("dirs", [])
    files = tool_context.state.get("files", [])
    doc_summaries_full = tool_context.state.get("doc_summaries", {})
    logger.debug(f"doc_summaries_full (raw from agent) type: {type(doc_summaries_full)}")

    doc_summaries = doc_summaries_full.get("summaries", {}) # remember, it has one top-level key called `summaries`
    project_summary = doc_summaries.pop("project", None)

    ####### More code...
Enter fullscreen mode Exit fullscreen mode
  1. We’re pulling both the directories list, and the files list, from the session state.
  2. We also pull the doc_summaries dictionary from session state, and then pop item with the key project.

Later, once we’ve created the output file, we return the path to this file, along with a success code:

    tool_context.state["llms_txt_path"] = llms_txt_path
    return {"status": "success", "llms_txt_path": llms_txt_path}
Enter fullscreen mode Exit fullscreen mode

Experimenting and Testing With a Notebook

As we’re building the application we want to be able to experiment with it and tweak it. Jupyter notebooks are perfect for this. (I have a quick intro to Jupyter notebooks here.) I created notebooks/generate_llms_experiments.ipynb for this purpose.

My Jupyter notebook

I’ll quickly walk you through it. You might find it useful for your own agentic model testing.

Environment Setup

We start with imports:

import os
import vertexai
from dotenv import load_dotenv
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.auth import default
from google.genai.types import Content, Part
from IPython.display import Markdown, display
Enter fullscreen mode Exit fullscreen mode

Then load environment variables from our .env. The notebook assumes we’ve already run scripts/setup-env.sh, but it’s useful to be able to quickly update these variables from a notebook cell:

dotenv_path = os.path.abspath('../.env')
if os.path.exists(dotenv_path):
    print(f"Loading environment variables from: {dotenv_path}")
    load_dotenv(dotenv_path=dotenv_path)
else:
    print(f"Warning: .env file not found at {dotenv_path}")

staging_project_id = os.getenv("GOOGLE_CLOUD_STAGING_PROJECT")
if staging_project_id:
    os.environ["GOOGLE_CLOUD_PROJECT"] = staging_project_id
    print(f"Set GOOGLE_CLOUD_PROJECT environment variable to: {staging_project_id}")
Enter fullscreen mode Exit fullscreen mode

Now pull credentials from Google ADC and initialise Vertex AI:

credentials, project_id = default() # To use ADC
vertexai.init(project=staging_project_id, location="europe-west4", credentials=credentials) # type: ignore
Enter fullscreen mode Exit fullscreen mode

Running the Agent

Setup our constants and helper functions:

from llms_gen_agent.agent import root_agent

# Session and Runner
APP_NAME = "generate_llms_client"
USER_ID="test_user"
SESSION_ID="test_session"
TEST_REPO_PATH = "/home/darren/localdev/gcp/adk-docs" # You can change this to any repository you want to test

async def setup_session_and_runner():
    session_service = InMemorySessionService()
    session = await session_service.create_session(app_name=APP_NAME, user_id=USER_ID, session_id=SESSION_ID)
    runner = Runner(agent=root_agent, app_name=APP_NAME, session_service=session_service)
    return session, runner

# Agent Interaction
async def call_agent_async(query):
    content = Content(role='user', parts=[Part(text=query)])
    _, runner = await setup_session_and_runner()

    events = runner.run_async(user_id=USER_ID, session_id=SESSION_ID, new_message=content)
    final_response_content = "Final response not yet received."

    async for event in events:
        if function_calls := event.get_function_calls():
            tool_name = function_calls[0].name
            display(Markdown(f"_Using tool {tool_name}..._"))
        elif event.actions and event.actions.transfer_to_agent:
            personality_name = event.actions.transfer_to_agent
            display(Markdown(f"_Delegating to agent: {personality_name}..._"))
        elif event.is_final_response() and event.content and event.content.parts:
            final_response_content = event.content.parts[0].text

        # For debugging, print the raw type and content to the console
        print(f"DEBUG: Full Event: {event}")

    display(Markdown("## Final Message"))
    display(Markdown(final_response_content))
Enter fullscreen mode Exit fullscreen mode

Note that the logging and output in the call_agent_async(query) function is particularly useful, since it allows us to see the full details of our events. This is really useful for debugging.

Finally, to invoke the agent:

query = f"Generate the llms.txt file for the repository at {TEST_REPO_PATH}"
await call_agent_async(query)
Enter fullscreen mode Exit fullscreen mode

And when we run it, we see output like this:

Running our agent from the notebook

Running the Agent with the ADK Web UI

But another cool way we can test our agent is with the ADK Web UI. The Agent Developer Kit has a built-in web UI that we can launch from the command line, and it’s very useful for testing our agent and visualing interactions between our agents and tools. We can launch it like this:

uv run adk web --port 8501 src
Enter fullscreen mode Exit fullscreen mode

Or just use the make playground command, as an alias. The UI opens in your browser, and we can issue our command to the coordinator agent. And we immediately see all the sub-agent and tool iteractions. Cool, right?

Using the ADK Web UI

But there’s so much we can see from the UI out of the box. Like…

Event flows, with payloads and request / response arguments:

Viewing the agent interactions with ADK Web UI

And our session state :

Examing sessions with ADK Web UI

And even tracing , so we can see where most of the time spent:

ADK Tracing

If you haven’t tried the ADK Web UI yet, you should. It’ll save you so much time!!

Handling Rate Limiting

My initial solution was quite inefficient. For example, I was using an off-the-shelf LangChain ReadFileTool to read each file separately. This meant that the model had to make a call to the tool once per file. (And also use a after-tool-callback to store the file contents in the session state.)

As a result I was generating a large number of Gemini API calls in a short amount of time. This was causing the 429 “too many requests” errors.

I’ve resolved the underlying issue by writing my own read_files() tool.(Which is super trivial.) But before doing that, I incorporated some rate-limiting into my solution.

A convenient way to perform rate limiting with Gemini models is to use google.genai.types.HttpRetryOptions and associate an instance of this with our models. Like this:

retry_options=HttpRetryOptions(
    initial_delay=config.backoff_init_delay,
    attempts=config.backoff_attempts,
    exp_base=config.backoff_multiplier,
    max_delay=config.backoff_max_delay
)

### Now, instead of this ###
# content_summariser_agent = Agent(
#     name="content_summarizer_agent",
#     description="An agent that summarizes collected file contents and aggregates them.",
#     model=config.model,
#     # remaining agent definition

### We do this ###
content_summariser_agent = Agent(
    name="content_summarizer_agent",
    description="An agent that summarizes collected file contents and aggregates them.",
    model=Gemini(
        model=config.model,
        retry_options=retry_options
    ),
    # remaining agent definition
Enter fullscreen mode Exit fullscreen mode

And I’ve defined these exponential backoff values in my .env:

# Exponential backoff parameters for the model API calls
export BACKOFF_INIT_DELAY=10
export BACKOFF_ATTEMPTS=5
export BACKOFF_MAX_DELAY=60
export BACKOFF_MULTIPLIER=2
Enter fullscreen mode Exit fullscreen mode

These settings mean:

  1. After the first 429 error, wait 10 seconds before trying again.
  2. Retry a maximum of 5 times.
  3. With each retry, double the wait time.
  4. Do not exceed 60 seconds between retries.

These parameters worked well for me. Anything less than this would cause persistent retry failures.

Adding the Frontend CLI

Having tested the agent using client code in my Jupyter notebook, it’s relatively trivial to port this into a command line utility. I decided to use the Python Typer package, because it does loads for me out-of-the-box, like argument parsing and setting up command line help.

I start by creating a /client_fe/runner.py module, and porting much of the client code, as-is, from my notebook:

"""
This module is responsible for running the llms-gen agent.
It sets up the necessary session and runner from the `google-adk` library,
and then invokes the agent with the user's query.
It also handles the streaming of events from the agent and displays the final response.
"""
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.genai.types import Content, Part
from rich.console import Console
from llms_gen_agent.agent import root_agent

APP_NAME = "generate_llms_client"
USER_ID = "cli_user"
SESSION_ID = "cli_session"
console = Console()

async def setup_session_and_runner():
    session_service = InMemorySessionService()
    await session_service.create_session(
        app_name=APP_NAME,
        user_id=USER_ID,
        session_id=SESSION_ID
    )
    runner = Runner(agent=root_agent, app_name=APP_NAME, session_service=session_service)
    return runner

async def call_agent_async(query: str) -> None:
    content = Content(role="user", parts=[Part(text=query)])
    runner = await setup_session_and_runner()

    events = runner.run_async(user_id=USER_ID, session_id=SESSION_ID, new_message=content)
    final_response_content = "Final response not yet received."

    async for event in events:
        if function_calls := event.get_function_calls():
            tool_name = function_calls[0].name
            console.print(f"\n[blue][bold italic]Using tool {tool_name}...[/bold italic][/blue]")
        elif event.actions and event.actions.transfer_to_agent:
            personality_name = event.actions.transfer_to_agent
            console.print(f"\n[blue][bold italic]Delegating to agent: {personality_name}...[/bold italic][/blue]")
        elif event.is_final_response() and event.content and event.content.parts:
            final_response_content = event.content.parts[0].text

    console.print("\n\n[yellow][bold]Final Message from Agent[/bold][/yellow]")
    console.print(f"[yellow]{final_response_content}[/yellow]")
Enter fullscreen mode Exit fullscreen mode

Note the use of console.print from the rich library. This library makes it really easy to make console output more visually appealing.

Next I created a separate client_fe/cli.py module for handling the user input and invoking the Runner:

"""
This module provides the command-line interface (CLI) for the LLMS-Generator application.

The primary function of this module is to accept a repository path from the user and initiate
the llms.txt generation process by calling the `call_agent_async` function from the `runner` module.

Usage:
    `llms-gen --repo-path /path/to/your/repo [--output-path /path/to/llms.txt] [--log-level DEBUG]`
"""
import asyncio
import os

# Load .env before other imports
from dotenv import find_dotenv, load_dotenv
# recursively search upwards to find .env, and update vars if they exist
if not load_dotenv(find_dotenv(), override=True):
    raise ValueError("No .env file found. Exiting.")

import typer
from rich.console import Console

from client_fe.runner import call_agent_async
from common_utils.logging_utils import setup_logger
from llms_gen_agent.config import current_config

app = typer.Typer(add_completion=False)
console = Console()

@app.command()
def generate(
    repo_path: str = typer.Option(
        ...,  # this means: required
        "--repo-path",
        "-r",
        help="The absolute path to the repository/folder to generate the llms.txt file for.",
    ),
    output_path: str = typer.Option(
        None,  # Optional
        "--output-path",
        "-o",
        help=("The absolute path to save the llms.txt file. If not specified, "
              "it will be saved in a `temp` directory in the current working directory.")
    ),
    log_level: str = typer.Option(
        None,
        "--log-level",
        "-l",
        help="Set the log level for the application. This will override any LOG_LEVEL environment variable."
    ),
    max_files_to_process: int = typer.Option(
        None,
        "--max-files-to_process",
        "-m",
        help="Set the maximum number of files to process. 0 means no limit."
    )
):
    """
    Generate the llms.txt file for a given repository.
    """
    if log_level:
        # Override log level from cmd line
        os.environ["LOG_LEVEL"] = log_level.upper()
        console.print(f":exclamation: Overriding LOG_LEVEL: [bold cyan]{log_level}[/bold cyan]")

    setup_logger(current_config.agent_name)

    if max_files_to_process:
        # Override max files to process from cmd line
        os.environ["MAX_FILES_TO_PROCESS"] = str(max_files_to_process)
        current_config.invalidate()
        console.print(f":exclamation: Overriding MAX_FILES_TO_PROCESS: [bold cyan]{max_files_to_process}[/bold cyan]")

    console.print(f":robot: Generating llms.txt for repository at: [bold cyan]{repo_path}[/bold cyan]")
    query = f"Generate the llms.txt file for the repository at {repo_path}"

    if output_path:
        query += f" and save it to {output_path}"

    # Show a spinner with
    console.status("[bold green]Generating file content... [/bold green]"):
        asyncio.run(call_agent_async(query))

    console.print("[bold green]:white_check_mark: llms.txt generation complete.[/bold green]")

if __name__ == "__main__":
    app()
Enter fullscreen mode Exit fullscreen mode

This is fairly easy to understand. Note how the __main__ entry point calls app(), which is in an instance of typer.Typer. We then use the app.command() decorator to make the generate() function use this Typer. In the generate() function you can see how easy it is to setup command line arguments with Typer.

So, for example, if we now run the llms-gen CLI command with no arguments, we get this:

Missing option in command line

And if we supply the --help command line argument, we get this:

With — help parameter

That was easy! You may be wondering how the cli.py is invoked simply by typing llms-gen at the command line. This is achieved with some more pyproject.toml magic. Just add this to the toml file:

[project.scripts]
llms-gen = "client_fe.cli:app"
Enter fullscreen mode Exit fullscreen mode

This creates a CLI tool called llms-gen and binds it to our app object inside the client_fe/cli.py module. So instead of having to run this:

python src/client_fe/cli.py
Enter fullscreen mode Exit fullscreen mode

We can just run this:

llms-gen
Enter fullscreen mode Exit fullscreen mode

Neat!

Let’s See It Running!

We need to supply a repo path. We can do so like this:

llms-gen --repo-path "/home/darren/localdev/gcp/adk-docs"
Enter fullscreen mode Exit fullscreen mode

But for convenience I’ve also created a target in my Makefile:

generate:
    @echo "Running LLMS-Generator..."
    uv run llms-gen --repo-path "/home/darren/localdev/gcp/adk-docs"
Enter fullscreen mode Exit fullscreen mode

So we can launch it like this:

make generate
Enter fullscreen mode Exit fullscreen mode

And this is what it looks like:

Running the CLI

Lookin’ good!

And the Output?

It’s created /temp/llms.txt which looks like this:

# adk-docs Sitemap

The Agent Development Kit (ADK) is a comprehensive, open-source framework designed to streamline the development, evaluation, and deployment of AI agents in both Python and Java. It emphasizes a code-first approach, offering modularity, flexibility, and compatibility with various models and deployment environments like Google Cloud's Vertex AI Agent Engine, Cloud Run, and GKE.

ADK provides core primitives such as Agents (LLM, Workflow, Custom), Tools (Function, Built-in, Third-Party, OpenAPI, Google Cloud, MCP), Callbacks, Session Management, Memory, Artifact Management, and a robust Runtime with an Event Loop for orchestrating complex workflows.

The documentation covers a wide array of functionalities, from basic agent creation and local testing using the Dev UI and CLI, to advanced topics like multi-agent system design, authentication for tools, and performance optimization through parallel execution. It also delves into crucial aspects of agent development such as observability with integrations like AgentOps, Cloud Trace, Phoenix, and Weave, and implementing strong safety and security guardrails using callbacks and plugins.

Furthermore, ADK supports grounding capabilities with Google Search and Vertex AI Search for accurate, real-time information retrieval. Overall, the ADK aims to empower developers to build sophisticated, context-aware, and reliable AI applications. The provided tutorials and quickstarts guide users through progressive learning, enabling them to master agent composition, state management, and deployment strategies for diverse use cases, ensuring agents can operate effectively and securely in production environments.

## Home

- [CONTRIBUTING.md](https://github.com/google/adk-docs/blob/main/CONTRIBUTING.md): Provides guidelines for contributing to the project, including signing a Contributor License Agreement, reviewing community guidelines, and setting up the development environment. It details the contribution workflow, from finding tasks to code reviews and merging pull requests.
- [README.md](https://github.com/google/adk-docs/blob/main/README.md): Introduces the Agent Development Kit (ADK) as an open-source, code-first framework for building, evaluating, and deploying AI agents. It highlights key features such as a rich tool ecosystem, modular multi-agent systems, tracing, monitoring, and flexible deployment options. Installation instructions for Python and Java are provided, along with links to documentation and contributing guidelines.

## Docs

- [community.md](https://github.com/google/adk-docs/blob/main/docs/community.md): Lists community resources for the Agent Development Kit, including translations of the ADK documentation in Chinese, Korean, and Japanese. It also features community-written tutorials, guides, blog posts, and videos showcasing ADK features and use cases. The document encourages contributions of new resources and directs users to the contributing guidelines.
- [contributing-guide.md](https://github.com/google/adk-docs/blob/main/docs/contributing-guide.md): Details how to contribute to the Agent Development Kit, covering contributions to core Python/Java frameworks and documentation. It outlines prerequisites like signing a Contributor License Agreement and reviewing community guidelines. The guide also explains how to report issues, suggest enhancements, improve documentation, and contribute code via pull requests.
- [index.md](https://github.com/google/adk-docs/blob/main/docs/index.md): Describes the Agent Development Kit (ADK) as a flexible, modular, and model-agnostic framework for developing and deploying AI agents, optimized for Google's ecosystem. It outlines key features including flexible orchestration, multi-agent architecture, a rich tool ecosystem, deployment readiness, built-in evaluation, and safety features. Provides installation commands for Python and Java, along with links to quickstarts, tutorials, API references, and contribution guides.

## Docs A2A

- [index.md](https://github.com/google/adk-docs/blob/main/docs/a2a/index.md): Introduces the Agent2Agent (A2A) Protocol within ADK, designed for building complex multi-agent systems where agents collaborate securely and efficiently. It provides links to guides on the fundamentals of A2A, quickstarts for exposing and consuming remote agents, and the official A2A Protocol website.
- [intro.md](https://github.com/google/adk-docs/blob/main/docs/a2a/intro.md): Introduces the Agent2Agent (A2A) Protocol for building collaborative multi-agent systems in ADK, contrasting it with local sub-agents. It outlines when to use A2A (separate services, different teams/languages, formal contracts) versus local sub-agents (internal organization, performance-critical operations, shared memory). The document visualizes the A2A workflow for exposing and consuming agents, and provides a concrete customer service example.
- [quickstart-consuming.md](https://github.com/google/adk-docs/blob/main/docs/a2a/quickstart-consuming.md): Provides a quickstart guide on consuming a remote agent via the Agent-to-Agent (A2A) Protocol within ADK. It demonstrates how a local root agent can use a remote prime agent hosted on a separate A2A server. The guide covers setting up dependencies, starting the remote server, understanding agent cards, and running the main consuming agent.
- [quickstart-exposing.md](https://github.com/google/adk-docs/blob/main/docs/a2a/quickstart-exposing.md): Provides a quickstart guide on exposing an ADK agent as a remote agent via the Agent-to-Agent (A2A) Protocol. It details two methods: using the `to_a2a()` function for automatic agent card generation and `uvicorn` serving, or manually creating an agent card for `adk api_server --a2a`. The guide includes steps for setting up dependencies, starting the remote server, verifying its operation, and running a consuming agent.

## Docs Agents

- [config.md](https://github.com/google/adk-docs/blob/main/docs/agents/config.md): Introduces the experimental Agent Config feature in ADK, allowing users to build and run agents using YAML text files without writing code. It outlines the setup process, including installing ADK Python libraries and configuring Gemini API access via environment variables. The document provides examples for building agents with built-in tools, custom tools, and sub-agents, and discusses deployment options and current limitations.
- [custom-agents.md](https://github.com/google/adk-docs/blob/main/docs/agents/custom-agents.md): Explains how to create custom agents in ADK by inheriting from BaseAgent and implementing `_run_async_impl` (or `runAsyncImpl` in Java) for arbitrary orchestration logic. It highlights use cases for custom agents, such as conditional logic, complex state management, external integrations, and dynamic agent selection, which go beyond predefined workflow patterns. The document provides a detailed example of a StoryFlowAgent to illustrate multi-stage content generation with conditional regeneration.
- [index.md](https://github.com/google/adk-docs/blob/main/docs/agents/index.md): Provides an overview of agents in the Agent Development Kit (ADK), defining an agent as a self-contained execution unit for specific goals. It categorizes agents into LLM Agents (for dynamic reasoning), Workflow Agents (for structured execution flow), and Custom Agents (for unique logic). The document emphasizes that combining these agent types in multi-agent systems allows for sophisticated and collaborative AI applications.
- [llm-agents.md](https://github.com/google/adk-docs/blob/main/docs/agents/llm-agents.md): Describes `LlmAgent` as a core component in ADK for AI agent reasoning, response generation, and tool interaction. It covers defining an agent's identity, guiding its behavior with `instruction` parameters, and equipping it with `tools`. The document also details advanced configurations such as `generate_content_config` for LLM response tuning, `input_schema`/`output_schema` for structured data, context management with `include_contents`, and the use of planners and code executors.
- [models.md](https://github.com/google/adk-docs/blob/main/docs/agents/models.md): Explains how to integrate various Large Language Models (LLMs) with the Agent Development Kit (ADK), supporting both Google Gemini and Anthropic models, with future plans for more. It details model integration via direct string/registry for Google Cloud models and wrapper classes for external models like those accessed through LiteLLM. Provides comprehensive authentication setups for Google AI Studio, Vertex AI, and outlines how to use LiteLLM for other cloud or local models.
- [multi-agents.md](https://github.com/google/adk-docs/blob/main/docs/agents/multi-agents.md): Explains how to build multi-agent systems in ADK by composing multiple `BaseAgent` instances for enhanced modularity and specialization. It details ADK primitives for agent composition, including agent hierarchy (parent/sub-agents) and workflow agents (`SequentialAgent`, `ParallelAgent`, `LoopAgent`) for orchestration. The document also covers interaction and communication mechanisms like shared session state, LLM-driven delegation (agent transfer), and explicit invocation using `AgentTool`.
- [index.md](https://github.com/google/adk-docs/blob/main/docs/agents/workflow-agents/index.md): Introduces workflow agents in ADK as specialized components for orchestrating the execution flow of sub-agents. It explains that these agents operate based on predefined, deterministic logic, unlike LLM Agents. Three core types are presented: Sequential Agents (execute in order), Loop Agents (repeatedly execute until a condition is met), and Parallel Agents (execute concurrently).
- [loop-agents.md](https://github.com/google/adk-docs/blob/main/docs/agents/workflow-agents/loop-agents.md): Describes the `LoopAgent` in ADK, a workflow agent that iteratively executes its sub-agents. It highlights its use for repetitive or iterative refinement workflows, such as document improvement. The document explains that `LoopAgent` is deterministic and relies on termination mechanisms like `max_iterations` or escalation signals from sub-agents to prevent infinite loops.
- [parallel-agents.md](https://github.com/google/adk-docs/blob/main/docs/agents/workflow-agents/parallel-agents.md): Describes the `ParallelAgent` in ADK, a workflow agent that executes its sub-agents concurrently to improve performance for independent tasks. It explains that sub-agents operate in independent branches without automatic shared history, requiring explicit communication or external state management if data sharing is needed. The document provides examples like parallel web research to illustrate its use for concurrent data retrieval or computations.
- [sequential-agents.md](https://github.com/google/adk-docs/blob/main/docs/agents/workflow-agents/sequential-agents.md): Details the `SequentialAgent` in ADK, a workflow agent that executes its sub-agents in a fixed, strict order. It emphasizes its deterministic nature, making it suitable for workflows requiring predictable execution paths. The document illustrates its use with a code development pipeline example, where output from one sub-agent is passed to the next via shared session state.

## Docs Get-Started

- [about.md](https://github.com/google/adk-docs/blob/main/docs/get-started/about.md): Provides an overview of the Agent Development Kit (ADK), an open-source, code-first toolkit for building, evaluating, and deploying AI agents. It outlines core concepts like Agents, Tools, Callbacks, Session Management, Memory, Artifact Management, Code Execution, Planning, Models, Events, and the Runner. The document highlights key capabilities such as multi-agent system design, a rich tool ecosystem, flexible orchestration, integrated developer tooling, native streaming support, built-in evaluation, broad LLM support, extensibility, and state/memory management.
- [index.md](https://github.com/google/adk-docs/blob/main/docs/get-started/index.md): Serves as a starting point for the Agent Development Kit (ADK), designed to help developers build, manage, evaluate, and deploy AI-powered agents. It provides links to installation guides, quickstarts for basic and streaming agents, a multi-agent tutorial, and sample agents. The page also offers an "About" section to learn core components of ADK.
- [installation.md](https://github.com/google/adk-docs/blob/main/docs/get-started/installation.md): Provides instructions for installing the Agent Development Kit (ADK) for both Python and Java. For Python, it recommends creating and activating a virtual environment using `venv` before installing via `pip`. For Java, it outlines adding `google-adk` and `google-adk-dev` dependencies using Maven or Gradle.
- [quickstart.md](https://github.com/google/adk-docs/blob/main/docs/get-started/quickstart.md): Provides a quickstart guide for the Agent Development Kit (ADK), detailing how to set up the environment, install ADK, create a basic agent project with multiple tools, and configure model authentication. It demonstrates running the agent locally using the terminal (`adk run`), the interactive web UI (`adk web`), or as an API server (`adk api_server`). The guide includes examples for defining tools and integrating them into an agent.
- [index.md](https://github.com/google/adk-docs/blob/main/docs/get-started/streaming/index.md): Introduces streaming quickstarts for the Agent Development Kit (ADK), emphasizing real-time interactive experiences through live voice conversations, tool use, and continuous updates. It clarifies that this section focuses on bidi-streaming (live) rather than token-level streaming. The page provides links to Python and Java quickstarts for streaming capabilities, custom audio streaming app samples (SSE and WebSockets), a bidi-streaming development guide series, and a related blog post.
- [quickstart-streaming-java.md](https://github.com/google/adk-docs/blob/main/docs/get-started/streaming/quickstart-streaming-java.md): Provides a quickstart guide for building a basic agent with ADK Streaming in Java, focusing on low-latency, bidirectional voice interactions. It covers setting up the Java/Maven environment, creating a `ScienceTeacherAgent`, testing text-based streaming with the Dev UI, and enabling live audio communication. The guide includes code examples for agent definition, `pom.xml` configuration, and a custom live audio application.
- [quickstart-streaming.md](https://github.com/google/adk-docs/blob/main/docs/get-started/streaming/quickstart-streaming.md): Provides a quickstart guide for ADK Streaming in Python, demonstrating how to create a simple agent and enable low-latency, bidirectional voice and video communication. It covers environment setup, ADK installation, agent project structure with a Google Search tool, and platform configuration (Google AI Studio or Vertex AI). The guide shows how to test the agent using `adk web` for interactive UI, `adk run` for terminal interaction, and `adk api_server` for API exposure, and notes supported models for live API.
- [testing.md](https://github.com/google/adk-docs/blob/main/docs/get-started/testing.md): Explains how to test ADK agents in a local development environment using the ADK API server. It provides commands for launching the server, creating sessions, and sending queries via `curl` for both single-response and streaming endpoints. The document also discusses integrations with third-party observability tools and options for deploying agents.

## Docs Grounding

- [google_search_grounding.md](https://github.com/google/adk-docs/blob/main/docs/grounding/google_search_grounding.md): Describes Google Search Grounding in ADK, a feature allowing AI agents to access real-time web information for more accurate responses. It details quick setup, grounding architecture (user query, LLM tool-calling, grounding service interaction, context injection), and response structure including metadata for source attribution. The guide provides best practices for displaying search suggestions and citations, emphasizing the tool's value for time-sensitive queries.
- [vertex_ai_search_grounding.md](https://github.com/google/adk-docs/blob/main/docs/grounding/vertex_ai_search_grounding.md): Explains Vertex AI Search Grounding in ADK, a feature enabling AI agents to access private enterprise documents for grounded responses. It covers quick setup, grounding architecture (data flow, LLM analysis, document retrieval, context injection), and response structure including metadata for citations. The guide emphasizes best practices for displaying citations and outlines the process for integrating and using Vertex AI Search with ADK agents.

## Docs Mcp

- [index.md](https://github.com/google/adk-docs/blob/main/docs/mcp/index.md): Introduces the Model Context Protocol (MCP) as an open standard for LLMs to communicate with external applications, data sources, and tools. It explains MCP's client-server architecture and how ADK facilitates using and exposing MCP tools. The document highlights MCP Toolbox for Databases, an open-source server for securely exposing various data sources as Gen AI tools, and mentions MCP Servers for Google Cloud Genmedia.
(The rest of the file...)
Enter fullscreen mode Exit fullscreen mode

Perfect! (I’ve trimmed the output file for the purposes of this blog.)

Using the llms.txt to Help Me With ADK

I’ve described much of this in the previous part. I start by cloning my pre-made ADK-Docs extension into my Gemini CLI global ~/.gemini/extensions folder. Then I update gemini-extension.json to point to my newly created llms.txt file, which I’ve renamed and moved to my adk-docs local folder:

{
  "name": "adk-docs-ext",
  "version": "1.0.0",
  "mcpServers": {
    "adk-docs-mcp": {
      "command": "uvx",
      "args": [
        "--from",
        "mcpdoc",
        "mcpdoc",
        "--urls",
        "/home/darren/localdev/gcp/adk-docs/dazbo-adk-llms.txt",
        "--transport",
        "stdio"
      ]
    }
  },
  "contextFileName": "GEMINI.md"
}
Enter fullscreen mode Exit fullscreen mode

And now we’re ready to test it in the Gemini CLI. I ask:

How do you find out information about ADK?

Gemini CLI responds:

How do you find out information about ADK?

And then with:

Gemini CLI can read my llms.txt!

OMG!! It’s all working!

Super excited!

Maybe the ADK-Docs folk might like to use it. I’ll reach out to them. And if you want to use this this llms.txt to help with your own ADK work, you can download the full version here.

How Long Did All This Take?

Whilst I think I’m a just-about-competent developer, this isn’t my day job. So a great developer knocking out code every day is probably going to be a lot faster. But for me, it took about 2 days (most of a weekend) to design and build the application to this point.

But when I build my next ADK application, I know I’ll be faster because:

  1. I’ll have already had this practice
  2. Gemini CLI will use my ADK llms.txt to help me be better and faster!

Multi-Agent ADK Tips and Lessons Learned

A quick summary of useful takeaways…

  • If you’re building a multi-agent system, make sure you design it up- front. Decide what each agent will do and what it is responsible for. Decide what tools will be used by each agent.
  • No-brainer here, but prompts need to be very specific! Leave no room for ambiguity, and one-shot prompting can be hugely beneficial.
  • Agent descriptions are extremely important; especially for sub-agents. They help your coordinator agent(s) to appropriately delegate tasks. The agent description is a property when defining the agent.
  • Similarly, tool descriptions are also very important. When creating custom tools you provide the description within the function docstring.
  • Your custom tool function docstrings should also be very clear about the arguments expected by the function ; but don’t mention the ToolContext argument in the docstring. This is injected implicitly by the ADK framework.
  • Be very clear about how you expect agents (and their tools) to either return data in their responses, or add data to session state. It is easy and convenient to use the property output_key to return data. Also, any object returned using output_key will automatically be added to session state.
  • If you need the output to be of a very specific format, consider setting the agent property output_schema and pass in a Pydantic schema.
  • Be careful not to try and add data to a session state key with a tool, whilst also trying to return an object with the same name using output_key. The latter will overwrite the former.
  • If your agent uses a tool and that tool will pull data from session state rather than input argument, you may need to explicitly tell the agent not to pass any arguments to the tool.
  • You can automatically inject any session state key into an agent prompt by using curly braces, e.g. “Tell me about {my_key}”.
  • Do use workflow agents when you want deterministic control of agent sequence.
  • Tool and agent after-callbacks are a great way to clean up response data to make sure it’s of the correct format for the next step in the workflow.
  • Jupyter notebooks are a great way to iteratively experiment with your agent.
  • The ADK Web UI is a fantastic tool for understanding what your agent is doing in detail. And easier than lots of debug logging!!

Ice Cream!

It also took me a fair while to write this article. Yeah, it’s a bit long. If you made it to the end, reward yourself with some ice cream!

A developer’s reward — created with Nano Banana

See you next time!

You Know What To Do!

  • Please share this with anyone that you think will be interested. It might help them, and it really helps me!
  • Please give me claps! (Just hold down the clap button.)
  • Feel free to leave a comment 💬.
  • Follow and subscribe, so you don’t miss my content.

Useful Links and References

The LLMS-Generator Repo

Google Cloud ADK

llms.txt

Gemini CLI

General


Top comments (0)