When you need this. A changelog line must fit a length limit, use a leading verb and name the identifiers that changed. A model can draft it, but code can check these requirements more precisely than another model. The useful next step is to return the broken rules to a reviser.
You will build a docuctl changelog loop: draft a line, lint it, revise it and check again. A session-state counter stops the loop when revisions run out. The output is either a passing line or the last attempt with its remaining problems. Passing this lint checks format and required names; you still need to judge whether the line describes the change correctly.
Get the files
You need Python 3.12+ and familiarity with graph routes and session state. Use Download code, extract the ZIP and open adk-iterative-refinement-lesson. Run:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txtadk-iterative-refinement-lesson/
requirements.txt
.env.example
change-rename.txt
change-config.txt
harness.py
run.py
refine_entry.py
refine-run.json
test_refine_entry.py
test_runner.pyThe comparison below needs no API key. For new replies, copy .env.example to .env and set your Gemini API key.
Each input starts with a change summary and lists exact identifiers on name: lines:
The build command's --out option is renamed to --output-dir, and the old name keeps working for one release with a deprecation warning. The SHIPCTL_OUT environment variable is renamed to SHIPCTL_OUTPUT_DIR in the same way.
name:docuctl build
name:--out
name:--output-dir
name:SHIPCTL_OUTPUT_DIRWrite the loop
"""Refine a one-line changelog entry in a graph loop until a lint passes or a round limit is hit."""
import re
from google.adk import Agent, Event, Workflow
from google.genai import types
from pydantic import BaseModel
MAX_ROUNDS = 3 # revisions after the first draft
MAX_CHARS = 80
VERBS = ("Add", "Fix", "Remove", "Rename", "Deprecate", "Change")
class Change(BaseModel):
summary: str
must_name: list[str]
class Entry(BaseModel):
text: str
class Feedback(BaseModel):
entry: str
problems: list[str]
def load_change(node_input: str):
"""Function node: the change summary, then one identifier per 'name:' line."""
lines = node_input.strip().splitlines()
names = [line[5:].strip() for line in lines if line.startswith("name:")]
summary = " ".join(line for line in lines if not line.startswith("name:"))
return Event(output=Change(summary=summary, must_name=names),
state={"must_name": names, "rounds": 0})
RULES = (f"At most {MAX_CHARS} characters. Start with one of: {', '.join(VERBS)}. "
"Put each name in must_name in backticks, exactly as written. No final period.")
def lint_entry(text: str, must_name: list[str]) -> list[str]:
"""The quality check: deterministic, so the loop's exit does not depend on a model."""
problems = []
if len(text) > MAX_CHARS:
problems.append(f"{len(text)} characters; the limit is {MAX_CHARS}")
if "\n" in text or "\r" in text:
problems.append("write one line")
if not re.match(r"^(?:" + "|".join(VERBS) + r") ", text):
problems.append(f"start with one of: {', '.join(VERBS)}")
problems += [f"name `{name}` in backticks" for name in must_name if f"`{name}`" not in text]
if text.endswith("."):
problems.append("drop the final period")
return problems
def check_entry(node_input: Entry, must_name: list[str], rounds: int):
"""Router node: done, another revision, or stop at the round limit."""
problems = lint_entry(node_input.text, must_name)
if not problems:
return Event(route="done", output=node_input.text)
if rounds >= MAX_ROUNDS:
return Event(route="limit", output=Feedback(entry=node_input.text, problems=problems))
return Event(route="revise", output=Feedback(entry=node_input.text, problems=problems),
state={"rounds": rounds + 1})
def accept(node_input: str, rounds: int):
return Event(message=f"PASSED after {rounds} revision(s): {node_input}")
def give_up(node_input: Feedback, rounds: int):
problems = "; ".join(node_input.problems)
return Event(message=f"STOPPED at the limit of {rounds} revisions. Last entry: "
f"{node_input.entry}\nStill failing: {problems}")
def build_workflow(model=None, reviser_sees_rules=True) -> Workflow:
model = model or "gemini-3.8-flash" # or LiteLlm(model="ollama_chat/gemma4:e2b")
write_entry = Agent(
name="write_entry",
model=model,
generate_content_config=types.GenerateContentConfig(
max_output_tokens=1024,
thinking_config=types.ThinkingConfig(thinking_level=types.ThinkingLevel.LOW)),
input_schema=Change,
output_schema=Entry,
# The rules live in lint_entry only; the feedback tells the model which ones it broke.
instruction="Write one changelog line for this change. Mention every name in must_name.",
)
revise_entry = Agent(
name="revise_entry",
model=model,
generate_content_config=types.GenerateContentConfig(
max_output_tokens=1024,
thinking_config=types.ThinkingConfig(thinking_level=types.ThinkingLevel.LOW)),
input_schema=Feedback,
output_schema=Entry,
# reviser_sees_rules=False reproduces the recorded oscillation: each revision
# fixes the listed problems and breaks a rule it was never shown.
instruction=("Rewrite the changelog line so that it fixes every listed problem. "
+ (f"Keep every rule: {RULES} must_name: {{must_name}}"
if reviser_sees_rules else "")),
)
return Workflow(
name="refine_entry",
edges=[
("START", load_change, write_entry, check_entry),
(check_entry, {"done": accept, "revise": revise_entry, "limit": give_up}),
(revise_entry, check_entry), # the back-edge that makes the loop
],
)load_change parses a typed Change and initializes must_name and rounds in session state. The writer returns an Entry. lint_entry returns a list of broken rules; check_entry uses that list and the counter to select a route:
done: no lint problems remain, soacceptreturns the line.revise: send the line and its problems torevise_entry, and incrementrounds.limit: return the final attempt and remaining problems throughgive_up.
The tuple (revise_entry, check_entry) is the back-edge: it sends the revision to a node already used in this invocation. ADK executes the check again with the new input and updated state. The graph cycle supplies repetition; your counter supplies the bound.
The reviser’s instruction includes the complete RULES as well as the latest problems. This helps it preserve rules that the previous draft already met. It still receives no permission to change the required identifiers or relax the lint.
The input cannot pass. The required identifiers in change-config.txt cannot fit the length rule. The reviser cannot resolve that contradiction, so the workflow returns its last attempt on limit. Check feasibility before requesting a draft when the minimum length can be computed.
The reviser sees only the latest problems. Without the complete rules, it can repair one issue and introduce another. The saved rename also passed when the reviser lacked the full rules, so this one comparison does not show that either instruction is better.
The cycle has no counter. A back-edge alone keeps running. Removing the rounds check leaves only the invocation’s model-call backstop, which raises LlmCallsLimitExceededError instead of returning the useful last-attempt message.
The model agents use LOW thinking and a 1,024-token output cap per request.
Run it
run.py compares a rename that can fit the lint rules with a config change whose required names cannot fit. Each invocation has a model-call backstop for the first draft plus MAX_ROUNDS revisions.
"""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 refine_entry import build_workflow, MAX_ROUNDS
async def compare(*, live=False):
record = json.loads((HERE / "refine-run.json").read_text())
for filename in ("change-rename.txt", "change-config.txt"):
saved = next(r for r in record["runs"] if r["change"] == filename and r["reviser_sees_rules"])
model = None if live else replay(saved, record)
rows = await invoke(build_workflow(model), (HERE / ("" if live else "historical-shipctl-source") / filename).read_text(),
max_llm_calls=1 + MAX_ROUNDS)
display(filename, 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 runs the same workflow code with the original shipctl change inputs from historical-shipctl-source/ and saved model replies. With --live, it reads the current docuctl inputs and requests new replies; no new docuctl model recording is supplied. It makes no model request and does not overwrite the fixtures. Watch the revise routes and the final done or limit route.
This is the saved rename’s final message:
Recorded example. The archived “Iterative refinement” demonstration retains the earlier shipctl scenario; the current lab uses docuctl and does not claim a new model result.
PASSED after 1 revision(s): Rename `shipctl build` `--out` to `--output-dir` (env: `SHIPCTL_OUTPUT_DIR`)To request new replies, copy .env.example to .env, set your Gemini API key and run python run.py --live. New wording can vary; compare the returned line with lint_entry.
Test the loop and its limits
python -W ignore -m unittest -v test_refine_entry.py test_runner.pyThe tests replay saved responses through the real graph and use controlled models for the unbounded-cycle case. They check route selection, state updates and stopping behavior without an API request.
The suite computes the shortest possible line that contains every required identifier. That line is longer than MAX_CHARS. The current runtime still sends it through the loop; an early feasibility check would avoid those attempts.
Revise against deterministic lint results
The check owns the exit condition. A model cannot end the loop merely by saying that it has fixed the draft. After every revision, the same lint_entry runs again.
State carries the revision count between executions. A revise event writes the next rounds value; ADK applies that delta before the reviser and following check run. The final limit check does not increment it because no further revision will happen.
This fixture extract shows the stopped run’s routes and state changes:
[
{
"path": "refine_entry@1/check_entry@1",
"route": "revise",
"state": {
"rounds": 1
}
},
{
"path": "refine_entry@1/check_entry@2",
"route": "revise",
"state": {
"rounds": 2
}
},
{
"path": "refine_entry@1/check_entry@3",
"route": "revise",
"state": {
"rounds": 3
}
},
{
"path": "refine_entry@1/check_entry@4",
"route": "limit"
}
]The @ suffix in a node path distinguishes repeated executions of that node. Keep counters in session state rather than a module variable, which would be shared by concurrent invocations in the process.
Choose the round limit
Each revision adds a model request and another check. Pick a small limit that fits the task, then return enough information for a caller to take over. Treat limit as its own outcome; a normal return does not mean the draft passed.
Use RunConfig.max_llm_calls as a second bound. It limits model requests for the invocation, while MAX_ROUNDS expresses this loop’s revision policy. Neither limit proves that the task is solvable.
Try an impossible requirement
Read the second input before running the comparison:
Six configuration keys move from the [build] table to a new [site] table, and docuctl migrate rewrites old files.
name:docuctl migrate
name:site.base_url
name:site.default_language
name:site.search_index_path
name:site.syntax_highlight_theme
name:site.navigation_depthCalculate the minimum length needed for its required names in backticks. Compare your calculation with the length bound in the source. Decide whether to shorten the required names, split the change into multiple entries or relax the length requirement. Increasing the round limit cannot resolve that contradiction.
Read a stopped result
A STOPPED message contains the last entry and the rules it still breaks. Compare those problems with the original requirements before editing the prompt. If the requirements conflict, change the input contract. If they fit, inspect which rule the latest revision lost.
Continue with Parallel fan-out and gather when several outputs can be prepared independently.
Read the explanation
A bounded loop needs a checkable goal and a distinct outcome when that goal cannot be met.
References
- ADK graph routes: back-edges and loop exits.
- ADK data handling: state and node inputs.
- ADK runtime configuration: model-call limits.
- ADK dynamic workflows: the Python-controlled alternative to graph loops.