// 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 ptsImplement revise_until_approved(writer, critic, task, max_rounds):
writer(task, feedback)returns a draft;feedbackisNonefor the initial draft, or the critic's feedback string for a revision.critic(draft)returnsNoneto 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 withwriter(task, feedback)and review again. - Perform at most
max_roundscritic 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.