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.
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:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txtadk-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.pyThe 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:
# 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
"""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.
"""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()python run.pyThe 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.
[
{
"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
python -W ignore -m unittest -v test_upgrade_guide.py test_runner.pyThe 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.
Read the explanation
Depth controls available delegation levels; a shared budget controls total model requests.
References
- ADK function tools: calling an agent through
AgentTool. - ADK collaborative workflows: delegation and agent modes.
- ADK plugins: callbacks shared with nested runs.
- ADK runtime configuration: per-invocation model-call limits.