aiml
SearchMy learning
Menu

Lesson 15 of 17Module 3

Iterative refinement

Refine a changelog line in an ADK 2.0 graph loop: a lint checks each draft, a back-edge sends problems to a reviser, and a state counter stops the loop.

Prerequisites

An ADK Workflow with conditional routes and session state, as in Module 2 of this course.

By the end, you can

  • Build an iterative refinement loop as a graph back-edge, with a deterministic quality check as its exit.
  • Bound the loop with a round counter in session state, and report the last attempt when it stops.
  • Recognize oscillating revisions and unbounded cycles, and stop a runaway loop with RunConfig.max_llm_calls.
Runtime
Python 3.12+; Gemini API key for optional live runs
Dependencies
google-adk 2.12.0 with google-genai 2.32.0

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

Progress is saved in this browser.

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.

refine_entry.py · Workflow refine_entry · back-edge revise_entry → check_entry · MAX_ROUNDS in state
data flowreturnfailurechangesummary · namestextrefine_entry · Workflowload_changerounds = 0Changewrite_entryAgent · EntryEntrycheck_entrylint · roundsdoneacceptPASSEDlimitgive_uplast entry · problemsrevise · rounds + 1revise_entryAgent · RULESrevised Entrydata flowreturnfailurechangesummary · namestextrefine_entryload_changerounds = 0Changewrite_entryEntrycheck_entrylint · roundsroutereviserevise_entryrevisedlimitgive_updoneaccept
The loop is one back-edge: each revised entry returns to check_entry, which runs the deterministic lint and reads the round count from session state. The writer and the reviser are model calls; whether the loop continues, passes or stops at the limit is decided by code.

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:

Set up the project environmentTerminal · bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements.txt
Project structureSource · text
adk-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.py

The 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:

change-rename.txtSource · textDownload
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_DIR

Write the loop

refine_entry.pySource · pythonDownload
"""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, so accept returns the line.
  • revise: send the line and its problems to revise_entry, and increment rounds.
  • limit: return the final attempt and remaining problems through give_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.

run.pySource · pythonDownload
"""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()
Compare a passing line with a stopped refinement loopTerminal · bash
python run.py

The 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.

Saved rename resultRecorded output · textDownload
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

Check revision and stopping behavior offlineTerminal · bash
python -W ignore -m unittest -v test_refine_entry.py test_runner.py

The 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:

Saved check routes and revision countsRecorded output · jsonDownload
[
  {
    "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:

change-config.txtSource · textDownload
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_depth

Calculate 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.

The required identifiers cannot fit the length limit. What should you change?

Choose one answer, then check your reasoning.

Read the explanation

A bounded loop needs a checkable goal and a distinct outcome when that goal cannot be met.

References

Written by Arjun Prabhulal.

Module 3

Workflow patterns