aiml
SearchMy learning
Menu

Lesson 17 of 17Module 3

Hierarchical task decomposition

Build a depth-limited ADK AgentTool tree for an upgrade guide, check its source paths and share a model-call budget across nested runs.

Prerequisites

ADK agents with function tools and plugins, and the collaborative workflow lesson in Module 2 of this course.

By the end, you can

  • Build a multi-level agent tree with AgentTool, and cap its depth so the last level can read but not delegate.
  • Record delegation across nested AgentTool runs with a plugin, and check the result with a function node.
  • Distinguish a depth limit from a tree-wide model-call budget, and enforce both in code.
Runtime
Python 3.12+; Gemini API key for optional live runs
Dependencies
google-adk 2.12.0 with google-genai 2.32.0

Lab code, tests and fixtures (ZIP, 56 KB)Quiz check your understanding

Progress is saved in this browser.

When you need this. “Write the upgrade guide from docuctl 3.4 to 3.6” contains several jobs: identify breaking changes, research the relevant pages and write a section for each change. A planner can delegate each section while a specialist focuses on one request.

You will build an agent tree with AgentTool, cap its depth in Python and share a model-call budget across nested runs. A final function checks that every section ends with a citation to an existing docs page. The application returns guide text; it runs no docuctl commands.

upgrade_guide.py · Workflow START → plan_guide → check_sources · AgentTool tree, max_depth = 3
data flowreturnfailuredevelopergoalplan_guide · AgentTool treeplan_guidedepth 1 · Agent noderequesttextwrite_sectiondepth 2 · AgentToolrequesttextresearch_docsdepth 3 · AgentTool · leafcallssearch_docsread_pagemax_depth = 2 makes write_section the leafpathdocs/Markdown pagesguide textcheck_sourcesevery Source: existsmessageguideno sourceValueErrordata flowreturnfailuredevelopergoalplan_guide treeplan_guidedepth 1requesttextwrite_sectiondepth 2requesttextresearch_docsdepth 3 · leafcallssearch_docs · read_pagepathdocs/Markdown pagesguidecheck_sourcesmessageguideno sourceValueError
Each level calls the one below through AgentTool with a request and gets back its final text; ADK also forwards child state changes. The depth limit is in the code that builds the tree: the last level gets the read-only docs tools instead of a delegation tool. The planner's guide leaves the workflow only if check_sources finds an existing page for every section. The shared DelegationLog plugin observes every level and enforces the tree-wide call budget.

Get the files

You need Python 3.12+ and the course’s function-tool, plugin and collaborative-workflow concepts. Use Download code, extract the ZIP and open adk-task-decomposition-lesson. Run:

Set up the project environmentTerminal · bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
Project structureSource · text
adk-task-decomposition-lesson/
  requirements.txt
  .env.example
  docs/
  harness.py
  run.py
  upgrade_guide.py
  historical_upgrade_guide.py
  historical-docs/
  guide-run.json
  test_upgrade_guide.py
  test_runner.py

The comparison below needs no API key. For new replies, copy .env.example to .env and set your Gemini API key.

The included docs/ folder contains the source pages, including this changelog:

docs/changelog.mdSource · markdownDownload
# Changelog

## 3.6.0
- Add `docuctl preview --port` to choose the preview server port.
- BREAKING: Remove the `--legacy-nav` flag. Navigation now comes only from `nav.toml`.

## 3.5.0
- Build pages in parallel; set the worker count with `--jobs`.
- BREAKING: `docuctl build` no longer reads `docuctl.yaml`; rename it to `docuctl.toml`.

Build the tree

upgrade_guide.pySource · pythonDownload
"""Write an upgrade guide with a three-level agent tree: plan, write sections, research docs."""
import re
from pathlib import Path

from google.adk import Agent, Event, Workflow
from google.genai import types
from google.adk.plugins import BasePlugin
from google.adk.tools import AgentTool

DOCS = Path(__file__).with_name("docs")
SOURCE = re.compile(r"^Source: (\S+\.md)$", re.M)


def search_docs(query: str) -> list[dict]:
    """Find docs pages that contain every word of the query. Returns path and title."""
    words = query.lower().split()
    hits = []
    for page in sorted(DOCS.rglob("*.md")):
        text = page.read_text()
        if all(word in text.lower() for word in words):
            hits.append({"path": str(page.relative_to(DOCS)), "title": text.splitlines()[0][2:]})
    return hits


def read_page(path: str) -> dict:
    """Return the text of one docs page, by the path that search_docs returned."""
    page = (DOCS / path).resolve()
    if not page.is_relative_to(DOCS.resolve()) or not page.is_file():
        return {"error": f"no docs page at {path}"}
    return {"path": path, "text": page.read_text()}


# One entry per level, top first. The depth limit cuts the tree: the last level
# that is built gets the docs tools instead of a delegation tool.
LEVELS = [
    ("plan_guide",
     "You write upgrade guides for docuctl, a docs-site CLI. Split the goal into one "
     "section per breaking change, and ask write_section to write each one. Then return "
     "the sections in version order under one '# ' title. Do not write sections yourself."),
    ("write_section",
     "Write one section of an upgrade guide: a '## ' heading, the steps a user takes, and a "
     "last line 'Source: <path>' naming the docs page the steps come from. Get every fact "
     "from research_docs; never use your own knowledge of docuctl."),
    ("research_docs",
     "Answer the question from the docuctl docs only. Use search_docs with one or two "
     "words, then read_page. Quote exact commands and the page path."),
]
MAX_DEPTH = len(LEVELS)


def build_tree(model, max_depth: int = MAX_DEPTH, level: int = 0) -> Agent:
    name, instruction = LEVELS[level]
    if level + 1 < min(max_depth, len(LEVELS)):
        tools = [AgentTool(build_tree(model, max_depth, level + 1))]
    else:
        tools = [search_docs, read_page]  # a leaf: it can only read, not delegate
        if name == "write_section":
            instruction = instruction.replace(
                "Get every fact from research_docs;",
                "Use search_docs with one or two words, then read_page. "
                "Get every fact from those pages;")
    return Agent(name=name, model=model,
                       generate_content_config=types.GenerateContentConfig(
                           max_output_tokens=1024,
                           thinking_config=types.ThinkingConfig(thinking_level=types.ThinkingLevel.LOW)), instruction=instruction, tools=tools,
                 description=instruction.split(".")[0])


def check_sources(node_input: str):
    """Function node: every section must cite a docs page that exists."""
    sections = re.split(r"(?m)^## [^\n]+\n", node_input)[1:]
    if not sections:
        raise ValueError("guide needs at least one section")
    cited = []
    for section in sections:
        lines = section.strip().splitlines()
        sources = SOURCE.findall(section)
        if len(sources) != 1 or not SOURCE.fullmatch(lines[-1]):
            raise ValueError("each section must end with one Source: <path> line")
        cited.append(sources[0])
    root = DOCS.resolve()
    missing = [path for path in cited
               if not (page := (root / path).resolve()).is_relative_to(root)
               or not page.is_file()]
    if missing:
        raise ValueError(f"missing pages: {missing}")
    return Event(message=node_input)


class DelegationLog(BasePlugin):
    """Records every agent start and tool call in the tree, with the agent's depth.
    AgentTool passes the runner's plugins to the nested runs (include_plugins=True)."""

    def __init__(self, max_model_calls: int | None = None):
        super().__init__(name="delegation_log")
        self.rows: list[dict] = []
        self.model_calls = 0
        self.max_model_calls = max_model_calls

    async def before_agent_callback(self, *, agent, callback_context):
        depth = [name for name, _ in LEVELS].index(agent.name) + 1
        self.rows.append({"depth": depth, "agent": agent.name})

    async def before_tool_callback(self, *, tool, tool_args, tool_context):
        self.rows.append({"agent": tool_context.agent_name, "calls": tool.name, "args": tool_args})

    async def before_model_callback(self, *, callback_context, llm_request):
        # One budget for the whole tree: each nested run counts its own max_llm_calls.
        self.model_calls += 1
        if self.max_model_calls is not None and self.model_calls > self.max_model_calls:
            raise RuntimeError(f"tree-wide model call budget of {self.max_model_calls} exceeded")


def build_workflow(model=None, max_depth: int = MAX_DEPTH) -> Workflow:
    model = model or "gemini-3.8-flash"  # or LiteLlm(model="ollama_chat/gemma4:e2b")
    return Workflow(name="upgrade_guide",
                    edges=[("START", build_tree(model, max_depth), check_sources)])

LEVELS defines the planner, section writer and researcher. build_tree wraps the next level in AgentTool, which exposes it as a callable tool. The planner sends a request to write_section; that agent can send another request to research_docs. Each nested call returns its final text to the caller.

The last level built receives search_docs and read_page instead of a delegation tool. This is the leaf: it can research but cannot delegate further. At max_depth=2, the section writer becomes the leaf and reads the pages itself. At depth three, it can ask the separate researcher.

read_page resolves paths and returns an error for files outside docs/ or missing pages. This restricts this tool’s reads; it does not isolate the entire Python process from the filesystem.

check_sources runs after the planner. It requires at least one section and one final Source: <path> line per section, with each path resolving inside docs/. It checks citation structure and file existence, not whether a page supports a command or whether the guide covers every breaking change.

The planner delegates a section twice. A planner can repeat a request without adding a new section. Inspect delegation as well as the final text; a well-formed guide can still contain avoidable work.

A section cites a page that does not exist. check_sources raises ValueError and the guide is not returned.

A leaf asks for a file outside the docs. read_page("../upgrade_guide.py") returns an error instead of the source file.

The model agents use LOW thinking and a 1,024-token output cap per request.

Compare delegation outcomes

run.py uses a depth-two tree for the same upgrade goal twice: first with a shared budget of 64 requests, then with a deliberately insufficient budget of one. These are chosen limits for the comparison. The saved full trial fits the larger bound. A final case sends a missing citation directly to check_sources.

run.pySource · pythonDownload
"""Compare the workflow's cases; replay saved replies unless --live is explicit."""
import argparse
import asyncio
import copy
import json
from pathlib import Path

from dotenv import load_dotenv
import os
from google.adk.agents.run_config import RunConfig
from google.adk.apps import App
from google.adk.runners import InMemoryRunner
from google.genai import types
from harness import ReplayLlm, summarize

HERE = Path(__file__).resolve().parent
from upgrade_guide import build_workflow, DelegationLog, check_sources
from historical_upgrade_guide import build_workflow as historical_workflow

async def compare(*, live=False):
    record = json.loads((HERE / "guide-run.json").read_text())
    saved = next(r for r in record["runs"] if (r["max_depth"], r["trial"]) == (2, 1))
    for budget in (64, 1):
        model = None if live else replay(saved, record)
        log = DelegationLog(max_model_calls=budget)
        try:
            builder = build_workflow if live else historical_workflow
            rows = await invoke(builder(model, max_depth=2), (record["input"].replace("shipctl", "docuctl") if live else record["input"]),
                                max_llm_calls=64, plugins=[log])
        except RuntimeError as error:
            if "tree-wide model call budget" not in str(error):
                raise
            print(f"\nbudget: {budget}\n{type(error).__name__}: {error}")
        else:
            display(f"depth: 2 | budget: {budget}", rows)
    # A file-existence check is independent of model generation.
    try:
        check_sources("# Guide\n\n## Upgrade\nRun docuctl migrate.\nSource: missing.md")
    except ValueError as error:
        print(f"\nmissing citation\n{type(error).__name__}: {error}")

async def invoke(root, text, *, max_llm_calls, plugins=()):
    """Run the actual workflow and close its in-memory Runner, including on failure."""
    app = App(name="docs_assistant", root_agent=root, plugins=list(plugins))
    runner = InMemoryRunner(app=app)
    session = await runner.session_service.create_session(app_name=runner.app_name, user_id="dev")
    try:
        return [summarize(event) async for event in runner.run_async(
            user_id="dev", session_id=session.id,
            new_message=types.Content(role="user", parts=[types.Part(text=text)]),
            run_config=RunConfig(max_llm_calls=max_llm_calls))]
    finally:
        await runner.close()


def replay(saved, record):
    return ReplayLlm(model=record["environment"]["model"],
                     calls=copy.deepcopy(saved["model_calls"]))


def display(label, rows):
    print(f"\n{label}")
    for row in rows:
        if row.get("route"):
            print("route:", row["route"])
    messages = [row["message"] for row in rows if row.get("message")]
    if messages:
        print(messages[-1])


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--live", action="store_true", help="request new Gemini replies instead of replaying saved replies")
    args = parser.parse_args()
    if args.live:
        load_dotenv(HERE / ".env", override=False)
        if not (os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY")):
            parser.error("Set your Gemini API key in .env before using --live")
    print("Mode:", "live Gemini requests" if args.live else "offline replay of the historical shipctl workflow and saved replies")
    asyncio.run(compare(live=args.live))


if __name__ == "__main__":
    main()
Compare a guide, a shared-budget stop and an invalid citationTerminal · bash
python run.py

The default replays the original shipctl depth-two tree from historical_upgrade_guide.py. Its prompt and saved replies stay unchanged, including the historical leaf instruction that names an unavailable research agent. The current docuctl builder uses the leaf’s actual search/read tools; it has no matching model recording. It makes no model request and does not replace fixtures. Expect a guide, a tree-wide budget error and a missing-page error. The budget callback counts attempted calls, including the one it prevents.

This short extract shows how the saved planner delegated to the section writer:

Recorded example. The archived “Hierarchical task decomposition” demonstration retains the earlier shipctl scenario; the current lab uses docuctl and does not claim a new model result.

Saved planner and section-writer callsRecorded output · jsonDownload
[
  {
    "depth": 1,
    "agent": "plan_guide"
  },
  {
    "agent": "plan_guide",
    "calls": "write_section",
    "args": {
      "request": "shipctl 3.5 breaking changes"
    }
  },
  {
    "agent": "plan_guide",
    "calls": "write_section",
    "args": {
      "request": "shipctl 3.6 breaking changes"
    }
  },
  {
    "depth": 2,
    "agent": "write_section"
  },
  {
    "depth": 2,
    "agent": "write_section"
  },
  {
    "agent": "write_section",
    "calls": "search_docs",
    "args": {
      "query": "3.6"
    }
  },
  {
    "agent": "write_section",
    "calls": "search_docs",
    "args": {
      "query": "3.5 breaking changes"
    }
  },
  {
    "agent": "write_section",
    "calls": "read_page",
    "args": {
      "path": "changelog.md"
    }
  }
]

To request new replies, copy .env.example to .env, set your Gemini API key and run python run.py --live. Review every section against its cited page. To explore the extra research level, change max_depth=2 to max_depth=3 only for a live run; the replay fixture contains the full depth-two conversation. Keep the shared budget in place.

Test the tree and its budget

Check delegation and limits offlineTerminal · bash
python -W ignore -m unittest -v test_upgrade_guide.py test_runner.py

The suite replays a saved trial, checks citation and path failures, and uses controlled models to test state propagation and call limits. Saved replies test the historical workflow. Controlled-model tests check the current tree’s wiring and limits; new live replies need a separate source review.

Delegate within a maximum depth

AgentTool creates a nested Runner and an in-memory session for the child request. The child starts with that request rather than the parent conversation, but ADK copies parent session state and forwards child state changes back. A separate conversation is therefore not state isolation.

By default, AgentTool(include_plugins=True) passes plugins to the nested Runner. Every level here sees the same DelegationLog object. Its callbacks record agent starts and tool requests, and its model callback increments one shared counter.

The depth bound and call budget solve different problems. Building only two levels removes the research delegation tool, so no prompt can add a third level through these tools. A planner can still call its existing section writer repeatedly, so depth alone cannot bound total work.

Budget the tree

max_llm_calls does not bound the tree. Nested runs inherit the configuration but count their requests separately. Giving each invocation the same limit still allows their combined total to exceed it.

A tree-wide budget is exceeded. DelegationLog.before_model_callback increments a counter shared across the tree and raises RuntimeError before a request beyond its budget. Create a new plugin instance for each independent guide.

Decide whether the planner is needed

Read the changelog and list its breaking changes yourself. If those entries already define the exact subtasks, a function could split them and call a writer for each one. Use a model planner when discovering the split needs judgment; otherwise the deterministic split is easier to inspect and bound.

Return to Choose an ADK workflow pattern and explain which decisions your application needs in code, in a model or in a person’s hands.

Why does the hierarchy need a shared plugin budget as well as max_llm_calls?

Choose one answer, then check your reasoning.

Read the explanation

Depth controls available delegation levels; a shared budget controls total model requests.

References

Written by Arjun Prabhulal.

Module 3

Workflow patterns