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.
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.
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txtOn 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:
unset GOOGLE_GENAI_USE_VERTEXAI DOCS_MODEL
export GOOGLE_GENAI_USE_ENTERPRISE=0Sending 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.
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 flagsWrite 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.
"""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:
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:
python graph_client.pyFind 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
"""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
python -m unittest -v test_release_notes.py test_client.pyThe 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.
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:
[
{
"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.
- ADK graph workflows: nodes and edges.
- ADK workflow data handling: input, output and state.
- ADK 2.0 overview: workflow and event APIs.