When you need this. A release announcement should explain what changed without inventing benefits. Asking the writer to check itself leaves one model responsible for both the draft and its review. We will give review its own source facts and return a typed decision that code can enforce.
You will build a docuctl announcement workflow: parse a changelog, write a draft, review its claims and return either the announcement or reasons for rejection. publish returns text to the caller; it does not post or deploy anything. A passing gate means this review accepted this draft, so a person still needs to assess important announcements.
Get the files
You need Python 3.12+ and the graph/state concepts from Module 2. Use Download code, extract the ZIP and open adk-generate-review-lesson. Run:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txtadk-generate-review-lesson/
requirements.txt
.env.example
changelog-3.5.0.md
changelog-3.4.0.md
harness.py
run.py
review_gate.py
review-run.json
test_review_gate.py
test_runner.pyThe default comparison needs no API key. For new replies, copy .env.example to .env and set your Gemini API key.
The input is one complete changelog section from our fictional docuctl CLI:
## 3.5.0
- Build pages in parallel; set the worker count with `--jobs`.
- Cache rendered code blocks in `.docuctl/cache`.
- Fix the search index missing pages whose title contains a colon.Write the workflow
Both model nodes use typed inputs and outputs. The functions around them parse facts, select a route and format the result.
"""Draft a release announcement with one agent and gate it with a second, reviewing agent."""
from typing import Literal
from google.adk import Agent, Event, Workflow
from google.genai import types
from pydantic import BaseModel, Field
class Changes(BaseModel):
version: str
entries: list[str]
class Draft(BaseModel):
title: str
body: str
class Review(BaseModel):
verdict: Literal["approve", "reject"]
unsupported: list[str] = Field(
description="Each sentence of the draft that no changelog entry supports, copied exactly.")
def parse_changelog(node_input: str, version_in_facts: bool = True):
"""The first line is the version, each '- ' line is one entry.
It also writes the facts the reviewer checks against to state."""
lines = [line.strip() for line in node_input.strip().splitlines() if line.strip()]
if not lines or not lines[0].startswith("## "):
raise ValueError("changelog must start with a '## <version>' heading")
entries = [line[2:] for line in lines[1:] if line.startswith("- ")]
if not entries:
raise ValueError(f"no entries under {lines[0]}")
changes = Changes(version=lines[0][3:], entries=entries)
facts = [f"- {e}" for e in entries]
if version_in_facts:
facts.insert(0, f"- docuctl {changes.version} is released.")
return Event(output=changes, state={"changelog": "\n".join(facts)})
def gate(node_input: Review, draft: dict):
"""Router node: the reviewer's verdict picks the edge. A verdict that lists
unsupported sentences is a rejection, whatever the verdict field says."""
verdict = "reject" if node_input.unsupported else node_input.verdict
return Event(route=verdict, output={"draft": draft, "unsupported": node_input.unsupported})
def publish(node_input: dict):
draft = node_input["draft"]
return Event(message=f"APPROVED\n# {draft['title']}\n\n{draft['body']}")
def send_back(node_input: dict):
problems = "\n".join(f"- {s}" for s in node_input["unsupported"])
return Event(message=f"REJECTED: the reviewer found claims with no changelog entry:\n{problems}")
WRITER = {
"upbeat": ("Write a short release announcement for users of docuctl, the docs-site CLI. "
"Make it upbeat and explain why each change matters to them. 3 to 5 sentences."),
"factual": ("Write a short release announcement for users of docuctl, the docs-site CLI. "
"Say what each entry changes, in plain words. Add no benefit, number or "
"comparison that an entry does not state. 3 to 5 sentences."),
}
def build_workflow(model=None, style="factual", version_in_facts=True) -> Workflow:
model = model or "gemini-3.8-flash" # or LiteLlm(model="ollama_chat/gemma4:e2b")
writer = Agent(
name="write_announcement",
model=model,
generate_content_config=types.GenerateContentConfig(
max_output_tokens=1024,
thinking_config=types.ThinkingConfig(thinking_level=types.ThinkingLevel.LOW)),
input_schema=Changes,
output_schema=Draft,
output_key="draft",
instruction=WRITER[style],
)
reviewer = Agent(
name="review_announcement",
model=model,
generate_content_config=types.GenerateContentConfig(
max_output_tokens=1024,
thinking_config=types.ThinkingConfig(thinking_level=types.ThinkingLevel.LOW)),
input_schema=Draft,
output_schema=Review,
instruction=(
"You fact-check release announcements. The only facts are these changelog "
"entries:\n{changelog}\n\nList every sentence of the draft that states a fact, "
"benefit or number that no entry supports. Approve only if the list is empty."
),
)
def read_changelog(node_input: str):
"""Function node. version_in_facts=False reproduces the reviewer's false rejection."""
return parse_changelog(node_input, version_in_facts)
return Workflow(
name="announce_release",
edges=[
("START", read_changelog, writer, reviewer, gate),
(gate, {"approve": publish, "reject": send_back}),
],
)Changes contains the version and entries. The writer returns a Draft; the reviewer returns a Review with an approve or reject verdict and a list of unsupported sentences. output_key="draft" keeps the writer’s output in session state so the gate can return that exact draft.
read_changelog also saves the source facts as changelog state. ADK substitutes {changelog} into the reviewer’s instruction. The reviewer receives the draft as input and the changelog as its reference, rather than judging the draft in isolation.
The writer adds unstated benefits. A request for upbeat copy can introduce claims the entries never made. The saved upbeat example is rejected. Narrow the writing instruction and inspect the review’s findings.
The reviewer lacks a fact. Omitting the version from its source facts can make the reviewer reject a true version sentence. Include every fact the draft is allowed to state.
The reviewer contradicts itself. gate forces reject when unsupported is non-empty, even if verdict says approve.
The input is not a changelog section. A missing ## heading raises ValueError before the writer runs.
The reviewer runs without its state. Removing the node that supplies changelog leaves an unresolved instruction variable and raises KeyError. Keep the state-producing node before the reviewer.
Run it
run.py compares a factual announcement, an upbeat draft and a review whose facts omit the version. It uses an in-memory Runner with a two-call limit for each invocation and prints its actual routes and messages.
"""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 review_gate import build_workflow
async def compare(*, live=False):
record = json.loads((HERE / "review-run.json").read_text())
cases = [("factual", "changelog-3.5.0.md", True),
("upbeat", "changelog-3.5.0.md", True),
("factual", "changelog-3.4.0.md", False)]
for style, filename, has_version in cases:
saved = next(r for r in record["runs"] if
(r["style"], r["changelog"], r["version_in_facts"]) == (style, filename, has_version))
model = None if live else replay(saved, record)
builder = build_workflow if live else __import__("importlib").import_module("historical-shipctl-source.review_gate").build_workflow
root = builder(model, style=style, version_in_facts=has_version)
rows = await invoke(root, (HERE / ("" if live else "historical-shipctl-source") / filename).read_text(), max_llm_calls=2)
display(f"{filename} | {style} | version fact: {has_version}", rows)
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 saved model replies")
asyncio.run(compare(live=args.live))
if __name__ == "__main__":
main()python run.pyThe default replays saved model replies through the preserved shipctl workflow in historical-shipctl-source/. The current docuctl workflow runs with --live and has no new model recording. Expect the factual case to be approved, the upbeat case to be rejected for unsupported claims and the missing-version case to reject a true version sentence. The command makes no model request and does not replace the fixtures.
Here is a concise extract from the factual case’s saved result:
Recorded example. The archived “Generate and review” demonstration retains the earlier shipctl scenario; the current lab uses docuctl and does not claim a new model result.
{"message":"{\"title\": \"shipctl 3.5.0 Release Announcement\", \"body\": \"shipctl version 3.5.0 is now available. The tool now builds pages in parallel, and you can set the worker count using the `--jobs` flag. Rendered code blocks are now cached in `.shipctl/cache`. Additionally, the search index no longer misses pages whose title contains a colon.\"}"}
{"message":"{\"verdict\":\"approve\",\"unsupported\":[]}"}
{"route":"approve"}
{"message":"APPROVED\n# shipctl 3.5.0 Release Announcement\n\nshipctl version 3.5.0 is now available. The tool now builds pages in parallel, and you can set the worker count using the `--jobs` flag. Rendered code blocks are now cached in `.shipctl/cache`. Additionally, the search index no longer misses pages whose title contains a colon."}Compare every returned claim with the input. To request new replies, copy .env.example to .env, set your Gemini API key and run python run.py --live. Existing shell variables take precedence. New wording and review decisions can differ.
Test the gate without a model
From the same project folder, run:
python -W ignore -m unittest -v test_review_gate.py test_runner.pyThe suite uses saved replies and controlled models to exercise the real workflow, parser, state and comparison entry point. It needs no API key. Passing replay tests checks routing for those replies; it does not measure how often a reviewer catches unsupported claims on new inputs.
Gate the reviewer’s publication verdict
The reviewer supplies findings for a decision; the gate owns the decision rule. The reviewer cannot rewrite the saved draft, and its approve value cannot override a non-empty findings list.
There is no edge from rejection back to the writer. Rejection returns to the caller, who can edit the source or instruction and start a new invocation. If the application should revise automatically, it also needs a revision policy and a stopping rule; the next lesson adds those.
Both agents use LOW thinking and a 1,024-token output cap per request. Typed output checks shape, while comparison with the changelog checks the claims.
Give the reviewer complete facts
Try removing the version fact by building with version_in_facts=False in a disposable copy. Before requesting a new response, predict the effect: the writer still receives Changes.version, while the reviewer loses that fact. Run the offline comparison for this case first, then inspect the different contexts in review_gate.py.
This exercise separates a source-context defect from a routing defect. A stricter gate cannot repair missing source facts in a reviewer’s prompt.
Continue with Iterative refinement to send specific check failures back to a reviser with a bounded loop.
Read the explanation
The reviewer supplies a typed opinion; the function gate enforces the application rule.
References
- ADK graph routes: function routers and named edges.
- ADK data handling: typed node outputs and session state.
- ADK LLM agents: structured output and instruction state.
- ADK runtime configuration: model-call limits.