aiml
SearchMy learning
Menu

Lesson 8 of 17Module 2

Route a workflow with conditional edges

Route documentation build logs with Python, use a default edge for incomplete runs and ask an agent for link suggestions only when needed.

Prerequisites

The previous graph-workflow lesson, Python 3.12 or later and a Gemini API key.

By the end, you can

  • Route a workflow with Event(route=...) and a route-to-node map in edges.
  • Use DEFAULT_ROUTE so that a route without an edge cannot end a run silently.
  • Decide when a code router beats letting a model choose the next step.
Runtime
Python 3.12+ and a Gemini API key
Dependencies
google-adk 2.12.0 with google-genai 2.32.0

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

Progress is saved in this browser.

When you need this. Your documentation build runs on every pull request. Most logs report no broken links, so sending every build to a model wastes work. We will read the log in Python and call an agent only when the build needs link suggestions.

A conditional edge selects the next node from a route value. Our router returns broken_links for a completed build with broken links, clean for a completed build without them, and incomplete when the log has no final completion line.

The graph sends broken links to an agent, clean logs to a short result, and unmatched routes to a rerun message. DEFAULT_ROUTE provides that last branch, including for route values we add later.

data flowartifact hand-offWorkflow · build_routerbuild-*.logdocs buildlogsession statepagesclassify_buildfunction nodestate=routeroutetriage_linksAgent nodegemini-3.8-flashcheck_fixesfunction nodereport_cleanask_for_rerunDEFAULT_ROUTE{pages} in instructionbroken_linkscleanany otherLinkFixesdeveloperfixesmessagemessagedata flowartifact hand-offbuild-*.logbuild_routerlog textclassify_buildfunctionstate=statepagesrouteroute{pages}broken_linkstriage_linksAgent · GeminiLinkFixescheck_fixescleanreport_cleanany otherask_for_rerunDEFAULT_ROUTEmessagedeveloper
Python chooses a route from the build log. Only broken_links reaches the agent; clean returns a result and DEFAULT_ROUTE asks for a rerun. The page list travels through session state into the agent instruction and page-membership check.

Prepare the build-log project

You need Python 3.12 or later and a Gemini API key. Use Download code, extract the ZIP, and open its adk-graph-routes-lesson directory. Keep the Python files and fixtures together. All commands in this lesson run from that directory.

Install the pinned dependenciesTerminal · bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt

On Windows PowerShell, activate with .venv\Scripts\Activate.ps1.

The Python client reads exported environment variables. The bundled .env.example documents them; it is not loaded by the client. Set GOOGLE_API_KEY privately, then select the Gemini API in this terminal:

Select the model backendTerminal · bash
unset GOOGLE_GENAI_USE_VERTEXAI DOCS_MODEL
export GOOGLE_GENAI_USE_ENTERPRISE=0

Sending a request to the model uses your provider account. The offline checks later in the lesson need no key.

Keep the three build-*.log files and docs-pages.txt beside the Python code. Here is the input that needs suggestions:

build-broken.logSource · textDownload
INFO    Building documentation for docs-assistant
WARNING broken link in quickstart.md: install.md#ollama-setup (anchor not found)
WARNING broken link in tools/search-docs.md: reference/citation.md (page not found)
WARNING broken link in troubleshooting.md: reference/cli-flags.md (page not found)
INFO    Build finished

Write the router and the branches

classify_build parses known warning lines and checks for INFO Build finished. The decision is a fact in the log, so Python can make it without asking a model.

build_router.pySource · pythonDownload
"""Route a docs-site build log: broken links go to a triage agent, a clean build ends early."""
import re
from pathlib import Path

from google.adk import Agent, Event, Workflow
from google.genai import types
from google.adk.workflow import DEFAULT_ROUTE
from pydantic import BaseModel

BROKEN = re.compile(r"^WARNING broken link in (\S+): (\S+) \((.+)\)$")
PAGES = Path(__file__).with_name("docs-pages.txt").read_text().split()


class BrokenLink(BaseModel):
    page: str
    target: str
    reason: str


class BrokenLinks(BaseModel):
    links: list[BrokenLink]


class LinkFix(BaseModel):
    page: str
    target: str
    replacement: str


class LinkFixes(BaseModel):
    fixes: list[LinkFix]


def classify_build(node_input: str):
    """Router node: parse the log and pick the next edge. No model call."""
    lines = node_input.strip().splitlines()
    links = [BrokenLink(page=m[1], target=m[2], reason=m[3])
             for m in map(BROKEN.match, lines) if m]
    if not lines or lines[-1] != "INFO    Build finished":
        return Event(route="incomplete", output=BrokenLinks(links=links))
    if links:
        return Event(route="broken_links", output=BrokenLinks(links=links),
                     state={"pages": PAGES})
    return Event(route="clean", output=BrokenLinks(links=[]))


def report_clean(node_input: BrokenLinks):
    return Event(message="Build passed: no broken links.")


def ask_for_rerun(node_input: BrokenLinks):
    return Event(message="The build log has no 'Build finished' line. Rerun the build before triage.")


def check_fixes(node_input: LinkFixes, pages: list[str]):
    """Keep only replacements that name a page that exists; flag the rest for a person."""
    lines = []
    for fix in node_input.fixes:
        page = fix.replacement.split("#", 1)[0]
        verdict = "ok" if page in pages else "needs a person: page does not exist"
        lines.append(f"- {fix.page}: {fix.target} -> {fix.replacement} ({verdict})")
    return Event(message="Suggested link fixes:\n" + "\n".join(lines))


def build_workflow(model=None) -> Workflow:
    triage = Agent(
        name="triage_links",
        model=model or "gemini-3.8-flash",  # or LiteLlm(model="ollama_chat/gemma4:e2b")
        generate_content_config=types.GenerateContentConfig(
            max_output_tokens=1024,
            thinking_config=types.ThinkingConfig(thinking_level=types.ThinkingLevel.LOW)),
        input_schema=BrokenLinks,
        output_schema=LinkFixes,
        instruction=(
            "For each broken link, suggest a replacement target. Use only these pages: "
            "{pages}. For a missing anchor, keep the page and suggest the closest anchor."
        ),
    )
    return Workflow(
        name="build_router",
        edges=[
            ("START", classify_build),
            (classify_build, {
                "broken_links": triage,
                "clean": report_clean,
                DEFAULT_ROUTE: ask_for_rerun,
            }),
            (triage, check_fixes),
        ],
    )

The returned event has two separate jobs. route chooses the edge; output carries BrokenLinks to the chosen node. A route label does not become the next node’s input.

On the broken-links branch, the router also writes the known page names to state. The agent’s {pages} instruction placeholder reads them when ADK builds the request. check_fixes then checks the suggested page names against that same list.

The model suggests a page that does not exist. check_fixes marks the suggestion needs a person: page does not exist. It still returns the report so the reader can review the problem.

The check strips any #fragment before comparing page names. (ok) means that the page exists in the supplied inventory; it says nothing about an anchor or whether a replacement section means the same thing. The workflow proposes edits and does not change files.

Wire the conditional edges

The route map connects labels to nodes:

Route labels and their destinationsSource · python
(classify_build, {
    "broken_links": triage,
    "clean": report_clean,
    DEFAULT_ROUTE: ask_for_rerun,
}),
(triage, check_fixes),

The default applies when no explicit key matches. The incomplete log therefore reaches ask_for_rerun, even though there is no "incomplete" key in the map. The broken-links branch continues through a plain edge to check_fixes.

Unmatched route without a default. Without DEFAULT_ROUTE, an incomplete route ends after the router event, with no exception or final message. Add a default when every run should explain its outcome.

Route type differs from the key. Event(route=True) does not match the string key "True". Use consistent route values and types.

Unreachable node. ADK raises ValidationError when a graph contains a node unreachable from START. Connect the node before constructing the workflow.

Run the router on three logs

Start with the code-only branches, then run the one that needs suggestions:

Compare the three branchesTerminal · bash
python graph_client.py --input build-clean.log
python graph_client.py --input build-incomplete.log
python graph_client.py --input build-broken.log

The clean log should return Build passed: no broken links. The incomplete log should ask you to rerun the build. Neither branch reaches an agent. The broken log should pass through triage_links and check_fixes; compare every suggested replacement with docs-pages.txt, then check its anchor yourself.

For each run, find the router event’s route and follow the later node_info.path values. This connects the branch decision to the nodes that actually ran.

Read the selected-input client
graph_client.pySource · pythonDownload
"""Run one supplied log through the original Workflow; no recorder or .env autoload."""
import argparse
import asyncio
import json
import os
from pathlib import Path
from google.adk.runners import InMemoryRunner
from build_router import build_workflow
from harness import run

HERE = Path(__file__).parent

def parse_options(argv=None):
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--input", type=Path, default=HERE / "build-broken.log", help="one existing UTF-8 log file")
    return parser.parse_args(argv)

async def run_one(options, model=None):
    text = options.input.read_text(encoding="utf-8")
    if not text.strip(): raise ValueError("input log must contain text")
    workflow = build_workflow(model=model or os.environ.get("DOCS_MODEL") or None)
    runner = InMemoryRunner(agent=workflow, app_name="docs_assistant")
    try:
        events, session_id = await run(workflow, text, runner=runner)
        result = {"session_id": session_id, "input_file": str(options.input), "events": events}
        print(json.dumps(result, ensure_ascii=False, indent=2))
        return result
    finally:
        await runner.close()

if __name__ == "__main__":
    asyncio.run(run_one(parse_options()))

Test every branch without a model

Check routing and page suggestions offlineTerminal · bash
python -m unittest -v test_build_router.py test_client.py

The tests replay saved replies through the actual workflow. Clean and incomplete cases supply no model reply, so an accidental edge to the agent fails instead of silently spending a request.

In a separate copy, remove the default route and run the matching offline check. Predict whether you will see an exception or just the router event. The test demonstrates why a successful process exit is a weak workflow completion check.

The router returns an unknown route and the graph has no default. What happens?

Choose one answer, then check your reasoning.

Read the explanation

An unmatched route has no outgoing destination. A default provides an explicit fallback.

Match route labels to graph edges

This fixture excerpt contains the router’s route, typed output and state together:

The broken-links routing eventRecorded output (excerpt) · jsonFull output
{
  "author": "build_router",
  "node_info": {
    "path": "build_router@1/classify_build@1",
    "output_for": [
      "build_router@1/classify_build@1"
    ]
  },
  "output": {
    "links": [
      {
        "page": "quickstart.md",
        "target": "install.md#ollama-setup",
        "reason": "anchor not found"
      },
      {
        "page": "tools/search-docs.md",
        "target": "reference/citation.md",
        "reason": "page not found"
      },
      {
        "page": "troubleshooting.md",
        "target": "reference/cli-flags.md",
        "reason": "page not found"
      }
    ]
  },
  "route": "broken_links",
  "state": {
    "pages": [
      "index.md",
      "install.md",
      "quickstart.md",
      "tools/search-docs.md",
      "reference/citations.md",
      "reference/cli.md",
      "troubleshooting.md"
    ]
  }
}

After a node completes, ADK follows its matching outgoing edges. A plain edge has no route condition. A route map selects the matching target, or its default when there is no explicit match.

A route can point back to an earlier node, but a graph cycle needs its own exit condition and bound. The matching offline check runs a self-route until a counter in the node reaches its stop condition.

Next, pause a workflow for human approval before it changes application state.

References

The code and commands use google-adk 2.12.0, as pinned in requirements.txt.

Written by Arjun Prabhulal.

Module 2

Workflows and Evals