// lesson: critic-loop

The Critic Loop

Pipelines only move forward. But the pattern that most improves output quality moves backward: generate a draft, have a critic review it, send it back for revision, repeat until the critic approves. Anthropic's agents article calls this evaluator–optimizer; in LangGraph it's a cycle in the graph. It works for the same reason code review works — evaluating a draft is easier than producing one, and a reviewer who didn't write the draft isn't attached to it.

It also introduces the classic multi-agent bug. "Repeat until the critic approves" — and what if it never approves? Critics with high standards and writers that can't meet them will happily ping-pong forever, and unlike an ordinary infinite loop this one spends money on every iteration: two API calls per round, at real per-token prices, until you notice. Every loop that depends on a model's judgment to terminate must carry a hard bound. This is non-negotiable, and the graded tests below are written so that a solution without the bound cannot pass.

First the real thing. Add a critic to team.py:

critic = make_agent(
    "critic",
    "You are an exacting editor reviewing a draft. If the draft is "
    "accurate, clear, and complete, reply with exactly: APPROVED\n"
    "Otherwise reply with a short list of specific, actionable fixes. "
    "Do not rewrite the draft yourself.",
    anthropic_client,
)

The critic is still just text-in, text-out. Two small adapters turn the raw agents into the shapes the loop needs — a verdict function that maps the critic's reply onto approve/revise, and a writer that knows how to fold feedback into a revision prompt:

def critic_verdict(draft):
    reply = critic(f"Review this draft:\n\n{draft}")
    if reply.strip() == "APPROVED":
        return None          # approval — nothing more to say
    return reply             # feedback for the writer

def revising_writer(task, feedback):
    if feedback is None:
        return writer(task)
    return writer(
        f"{task}\n\nYour previous draft was reviewed. "
        f"Revise it to address this feedback:\n{feedback}"
    )

The exact-sentinel check (APPROVED, matched exactly) is the same move as the NOT_IN_CONTEXT sentinel in the RAG course: a fixed token your code can match mechanically beats parsing prose like "This looks pretty good to me!" Returning None for approval keeps the loop's contract crisp: feedback is a string, approval is the absence of feedback.

Now the loop itself — the graded function:

def revise_until_approved(writer, critic, task, max_rounds):
    draft = writer(task, None)
    for _ in range(max_rounds):
        feedback = critic(draft)
        if feedback is None:
            return draft
        draft = writer(task, feedback)
    return draft

Count the calls carefully, because the tests do. One initial draft. Then at most max_rounds critic reviews; each rejection buys one revision. If the critic never approves, the loop performs exactly max_rounds reviews and max_rounds revisions and returns the best draft it has — a bounded system degrades gracefully instead of hanging. max_rounds=3 means at most 7 API calls, a number you can put in a budget.

Wire it up and watch a revision happen:

if __name__ == "__main__":
    task = "Write three sentences on why retries need exponential backoff."
    final = revise_until_approved(revising_writer, critic_verdict, task, max_rounds=3)
    print(final)

Add a print inside critic_verdict to see the verdicts fly past. Most tasks get approved in a round or two; tighten the critic's standards ("be extremely strict; approve only flawless drafts") and watch it use the whole budget — and still terminate. That's the bound earning its keep.

In the tests, the writer and critic are stubs with call counters, and the never-approves critic raises if called too many times — so a loop without a bound doesn't hang the grader; it fails fast, loudly, the way your bank account would have.

Bound the Critic Loop

20 pts

Implement revise_until_approved(writer, critic, task, max_rounds):

  • writer(task, feedback) returns a draft; feedback is None for the initial draft, or the critic's feedback string for a revision.
  • critic(draft) returns None to approve, or a feedback string.
  • Produce the initial draft with writer(task, None), then review it. On approval, return the current draft immediately. On feedback, revise with writer(task, feedback) and review again.
  • Perform at most max_rounds critic reviews. If the last allowed review still rejects, revise one final time and return that draft anyway — never review or revise beyond the budget.
  • You may assume max_rounds >= 1.

Log in to submit a solution and earn points.