aiml
SearchMy learning
Menu

Lesson 7 of 17Module 2

Build a graph workflow from nodes and edges

Build a release-notes workflow with function nodes, a typed agent node and a deterministic check that every commit reaches the output.

Prerequisites

Python 3.12 or later, an ADK agent run through a Runner, and a Gemini API key.

By the end, you can

  • Build an ADK Workflow from function nodes and an Agent node connected by edges.
  • Pass typed data between nodes with Event output, node input, Pydantic schemas and session state.
  • Test a workflow without a model by replaying a recorded reply, and pin the failures that stop a run.
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, 11 KB)Quiz check your understanding

Progress is saved in this browser.

When you need this. Your team wants release notes from a git log. A model can group the changes, but parsing the input, checking that no commit disappeared and formatting Markdown should follow rules you can test. Let’s put those jobs in a fixed sequence.

A node performs one step. An edge says which node runs next. ADK’s Workflow joins them: parse_log extracts commit subjects, group_changes assigns categories, and render_notes checks the groups before returning the release notes.

Only the middle node calls Gemini. The model chooses a category; Python controls the order and checks that the grouped subjects match the input, including duplicate counts. That check cannot tell whether a feature belongs under Fixes instead.

data flowartifact hand-offfailuresample-log.txtgit log --onelinedeveloperrelease_notesparse_logfunction nodegroup_changesAgent nodegemini-3.8-flashrender_notesfunction nodeSTART · log textCommitsReleaseGroupsmessage: Markdownsession statesubjectsstate=subjectsValueErrornot a gitlog lineValueErrorgroups≠ logdata flowartifact hand-offfailuresample-log.txtrelease_noteslog textparse_logfunctionValueErrorbad lineCommitsgroup_changesAgent · Geministate=subjectsstatesubjectsReleaseGroupssubjectsrender_notesfunctionValueErrorgroups ≠ logMarkdowndeveloper
Edges run the parser, grouping agent and renderer in order. The parser also saves subjects in state so the renderer can check the groups against the original input. A malformed log or mismatched group stops the run.

Prepare the release-notes project

You need Python 3.12 or later and a Gemini API key. Use Download code, extract the ZIP, and open its adk-graph-workflow-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.

The input uses git log --oneline format. The hexadecimal prefix identifies the commit; the rest of each line is its subject.

sample-log.txtSource · textDownload
9f3c2a1 Add a search_docs tool that returns page titles and URLs
4b7e910 Fix broken anchor links in the quickstart page
c21d8e4 Document the OLLAMA_API_BASE setting in the install guide
7a0f5b3 Add citation links to every answer
e5d4c12 Fix a crash when a docs page has no front matter
1c9b6f7 Rewrite the troubleshooting page for the new CLI flags

Write the workflow

We need two data shapes: Commits carries the subjects and ReleaseGroups carries the categories. The same file defines those Pydantic models, the two function nodes and the agent.

release_notes.pySource · pythonDownload
"""Turn `git log --oneline` output into grouped release notes with an ADK graph workflow."""
import re

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

LINE = re.compile(r"^([0-9a-f]{7,40}) (.+)$")


class Commits(BaseModel):
    subjects: list[str]


class ReleaseGroups(BaseModel):
    features: list[str]
    fixes: list[str]
    docs: list[str]


def parse_log(node_input: str):
    """Function node: keep each commit subject, refuse anything that is not a log line."""
    subjects = []
    for line in node_input.strip().splitlines():
        match = LINE.match(line.strip())
        if not match:
            raise ValueError(f"not a git log --oneline line: {line!r}")
        subjects.append(match[2])
    return Event(output=Commits(subjects=subjects), state={"subjects": subjects})


def render_notes(node_input: ReleaseGroups, subjects: list[str]):
    """Function node: check that every commit is listed once, then write Markdown."""
    grouped = node_input.features + node_input.fixes + node_input.docs
    if sorted(grouped) != sorted(subjects):
        missing = sorted(set(subjects) - set(grouped))
        extra = sorted(set(grouped) - set(subjects))
        raise ValueError(f"groups do not match the log: missing={missing} extra={extra}")
    sections = [("Features", node_input.features), ("Fixes", node_input.fixes),
                ("Documentation", node_input.docs)]
    body = "\n\n".join(f"## {title}\n" + "\n".join(f"- {item}" for item in items)
                       for title, items in sections if items)
    return Event(message=f"# Release notes\n\n{body}")


def build_workflow(model=None, thinking_level=types.ThinkingLevel.LOW) -> Workflow:
    config = types.GenerateContentConfig(max_output_tokens=1024)
    if thinking_level:  # e.g. "low": less hidden reasoning for a simple sorting task
        config = types.GenerateContentConfig(
            max_output_tokens=1024,
            thinking_config=types.ThinkingConfig(thinking_level=thinking_level))
    group_changes = Agent(
        name="group_changes",
        model=model or "gemini-3.8-flash",  # or LiteLlm(model="ollama_chat/gemma4:e2b")
        generate_content_config=config,
        input_schema=Commits,
        output_schema=ReleaseGroups,
        instruction=(
            "Sort every commit subject into exactly one list: features (new behavior), "
            "fixes (a bug or crash fixed) or docs (documentation pages only). "
            "Copy each subject exactly as written."
        ),
    )
    return Workflow(
        name="release_notes",
        edges=[("START", parse_log, group_changes, render_notes)],
    )

parse_log returns Event(output=Commits(...)). ADK makes that output the next node’s input. The agent declares input_schema=Commits and output_schema=ReleaseGroups, so it receives JSON and must return an object with the expected lists.

The renderer also needs the original subjects. parse_log writes them to session state, and ADK binds the renderer’s subjects parameter from state by name. The model handles the grouping; it does not carry the original list on behalf of the application.

The agent uses LOW thinking and a 1,024-token output limit. A response can end before its JSON is complete; the limit applies to one response, while the workflow determines how many requests run.

Input is not git log output. A malformed line raises ValueError: not a git log --oneline line before the agent runs. Fix the input format rather than asking the model to guess.

The model drops a commit. render_notes compares the sorted subjects and refuses a missing, added or repeated entry with ValueError: groups do not match the log. It returns no release notes for that result.

A node yields two outputs. ADK raises ValueError: Output already set. A node can produce at most one output. Use Event(message=...) for progress and one Event(output=...) for the data passed onward.

Connect the nodes with edges

The last lines of build_workflow define the chain:

The three-node sequenceSource · python
Workflow(
    name="release_notes",
    edges=[("START", parse_log, group_changes, render_notes)],
)

START marks the entry point. Each item after it runs after its predecessor finishes. Plain Python functions in the chain become function nodes; an Agent becomes an agent node.

An agent with parse and render tools would let the model choose whether and when to call those tools. Here the sequence is part of the application requirement, so we put it in edges.

A state parameter has no writer. If nothing writes subjects, the renderer fails with WorkflowDataError: Missing value for parameter "subjects". A function parameter that comes from state needs a matching state key.

Run the workflow and read the events

The client reads the log, creates an InMemoryRunner, prints the workflow events and closes the Runner:

Generate release notes from the bundled logTerminal · bash
python graph_client.py

Find the final event’s message and compare its Markdown with sample-log.txt. Each original subject should appear once. Then read each event’s author and node_info.path to follow the parser, agent and renderer in sequence.

To use another log, save git log --oneline output in a separate file and run python graph_client.py --input my-log.txt. The client expects log text, rather than a conversational request.

Read the Python 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 release_notes 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 / "sample-log.txt", 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 the workflow without a model

Check the graph and failure paths offlineTerminal · bash
python -m unittest -v test_release_notes.py test_client.py

The tests run the real workflow with saved model replies and explicit fakes. They check input parsing, state binding and the membership check without contacting Gemini. Matching a saved request also catches an unexpected change to the model input.

Try changing the replay fixture in a separate copy so one commit is absent. Predict which node should fail, then run the matching offline check. The renderer should catch the omission even though the reply has valid JSON and all required fields.

What does render_notes check after the model groups the commits?

Choose one answer, then check your reasoning.

Read the explanation

The renderer compares sorted subjects, including duplicate counts. It does not judge category quality.

Pass typed data between graph nodes

An agent placed directly in edges runs in single_turn mode. It receives the previous output, produces one result and returns control to the workflow. The output schema checks the object’s shape; the renderer checks the application rule.

This excerpt from the bundled workflow fixture shows which node produced each event:

Node identities in the sample event streamRecorded output (excerpt) · jsonFull output
[
  {
    "author": "release_notes",
    "node_info": {
      "path": "release_notes@1/parse_log@1",
      "output_for": [
        "release_notes@1/parse_log@1"
      ]
    }
  },
  {
    "author": "group_changes",
    "node_info": {
      "path": "release_notes@1/group_changes@1",
      "output_for": [
        "release_notes@1/group_changes@1"
      ]
    }
  },
  {
    "author": "release_notes",
    "node_info": {
      "path": "release_notes@1/render_notes@1"
    }
  }
]

node_info.path identifies a node execution. output_for identifies whose output an event represents. These fields let a client inspect progress without parsing the release-note text.

If a node raises, the exception reaches the caller of run_async. This client prints the result; it does not write a release file. Your application should publish notes only after the workflow finishes and a person accepts the categories.

Continue with conditional edges when different inputs need different steps.

References

The code and commands use the installed google-adk 2.12.0 interfaces. The requirements file pins the ADK version.

Written by Arjun Prabhulal.

Module 2

Workflows and Evals