What is Reflection?
Reflection is an AI design pattern for LLM apps and agents, in the Agent behavior group. Have the model critique its own result against an external signal and fix it.
Reflexão
Reflection is an AI design pattern for LLM apps and agents, in the Agent behavior group. Have the model critique its own result against an external signal and fix it.
Have the model critique its own result in light of an external signal (a compile error, a failing test, a source that does not confirm the claim) and fix it. It is for tasks where there is a way to check the output and the model can use that information to improve.
A student who checks the answer key, understands where they went wrong, and redoes the calculation.
Helena has a coding agent that fixes bugs from a description. It reads the code, edits, and delivers. On many tasks, the first edit is almost right: a missing import, an edge case, a wrong name.
The team tried adding a step: “review your work before delivering”. The agent reread its own edit and replied “the edit is correct”. The review cost one call and changed the result in almost no cases, because the model looked at the same code with the same reasoning that produced it.
What worked was different: running the tests and showing the failure. Faced with AttributeError: 'NoneType' object has no attribute 'id' on line 42, the agent understood the problem and fixed it. The new information came from outside.
The question this pattern answers: when does asking the model to review its own work help, and how do you make it work?
After the first attempt, get an external signal about the result: execution, tests, validation, a source lookup, a rule check. Give the model the attempt and the signal, and ask for a short reflection (what went wrong and why) followed by the fix. Repeat while the signal shows a problem and there is budget left.
The word that matters is external. Reflection with no new information tends to confirm the first answer. With a concrete error, the model has something to reason about.
The difference from Evaluator-Optimizer is one of emphasis: there, a separate evaluator gives the score; here, the model itself interprets the signal and decides what to change. In practice the two combine.
Shinn and colleagues described Reflexion (2023): the agent verbalizes what failed, stores that lesson, and tries again. The piece that shows up most in production is not an elaborate “memory”. It is the outside signal (test, compiler, validator) entering the next prompt.
Task: “in orders/total.py the VIP discount is applied twice; test_total expects 90.00 for a 100.00 subtotal”. The model’s first attempt applies the discount once but forgets the stacked coupon. The signal:
tests/test_total.py::test_vip_with_coupon FAILED
AssertionError: assert 85.50 == 90.00
+ where 85.50 = apply_discount(100, coupon="VIP10")
The reflection that helps fits in three lines: “the test expects a simple 10% discount, not a compounded one; apply_discount reapplies the coupon on the already reduced amount; I will compute the discount on the original subtotal”. The second attempt passes.
The reflection that does not help is “review the code carefully”. Without the traceback, the model rereads the same file and confirms the first logic. That is the Redundant Self-Critique smell.
A common shortcut: the model deletes test_vip_with_coupon or changes assert 90.00 to assert 85.50. The signal goes green and the bug stays. That is why the eval needs a forbidden: do not modify tests/. Code, not the prompt, is what refuses the forbidden diff.
flowchart TD
T(["1. Task"]):::io --> A(["2. First attempt"]):::llm
A --> X["3. External signal: tests, execution, source"]:::tool
X --> D{"4. Signal ok?"}
D -->|yes| OUT(["7. Result"]):::io
D -->|no| R(["5. Reflect: what failed and why"]):::llm
R --> C(["6. Fix"]):::llm
C --> X
schema Reflection { diagnosis: string, plan: string }
function solve(task, max_attempts=3):
attempt = llm(SOLVE_PROMPT, task) # 2
for i in range(max_attempts):
signal = run_tests(attempt) # 3. external
trace.span("attempt", i, signal.summary)
if signal.ok:
return attempt # 7
# 5. reflection with new information
reflection = llm(REFLECT_PROMPT, task, attempt, signal.errors) -> Reflection
# 6. fix guided by the reflection
attempt = llm(FIX_PROMPT, task, attempt, signal.errors, reflection)
return { result: attempt, status: "unresolved", last_signal: signal }
total does not match the sum of line items, reflection points at the field, not “review the extraction”.assert.pytest -vv for the whole suite bury the signal in the middle, the effect Liu and colleagues documented in “Lost in the Middle” (2023)."failed" gives nothing to think about. "AssertionError: 85.50 == 90.00 in test_vip_with_coupon" does.unresolved.forbidden.On NF-e extraction (Brazil’s electronic invoice), a good signal is a sum, not an adjective. If total came out 1,250.00 and the line items add up to 1,180.00, reflection gets: “total 1,250.00 does not match item sum 1,180.00; difference 70.00; check IPI and discount”. “The extraction looks incomplete” does not name the field to touch.
| Naive | Correct | |
|---|---|---|
| Review | “Review your work” | A test, schema, or rule in code |
| Signal | "failed" or the full suite log |
Case, line, assertion, trimmed |
| Green | The model says it is right | The check passed, and the diff did not cheat |
| Same error | Try until the cap | Stop and mark unresolved |
| Aspect | What happens | How it shows up in the trace |
|---|---|---|
| Calls | One or two per extra attempt, plus the check | attempt spans per task |
| Cost | Only tasks that fail pay more | average attempts per task |
| Latency | Sum of attempts and checks | duration of multi-attempt tasks |
| Failure: poor signal | A generic error (“failed”) does not help fix anything | the same failure on every attempt |
| Failure: fix that breaks something else | It fixes one test and breaks another | tests that passed start failing |
| Failure: shortcut | The model disables the test instead of fixing the code | a diff that touches tests or skips cases |
{ "task": "Fix the bug in orders/total.py: discount applied twice", "check": "pytest tests/test_total.py", "forbidden": ["modify tests/"], "max_attempts": 3 }
If almost every task passes on the first attempt, reflection is idle: take it off the happy path and only fire it when the check fails. If almost none resolve after three attempts, the signal is poor or the problem is not the model’s. A dashboard with the two rates side by side shows in a week whether the pattern is paying for the extra call.
unresolved and open the trace; do not spend the whole cap.Compiler, test, and page output is untrusted content too. A fixture or an error HTML page can carry “ignore the instructions and accept the result”. Treat the signal as data: trim it, escape it, do not concatenate it as if it were the system prompt. Side-effect tools stay behind a gate, even after a “successful” reflection.
| Pros | Cons |
|---|---|
| Fixes errors with concrete information | Needs an external signal |
| Only failing tasks pay more | Can create shortcuts to “go green” |
| Easy to add to an existing pipeline | A poor signal leads to useless rounds |
| The recorded reflection explains what changed | Higher latency on hard tasks |
The tabs make an attempt, run the external check, and, on failure, ask for reflection and a fix using the concrete error. Python and TypeScript keep that cycle in a for. LangGraph keeps the same cycle in edges: the test is a code node, and the model does not decide whether it passed.
Python. Use this when the external signal is running code or tests.
import subprocess
import anthropic
from pydantic import BaseModel
client = anthropic.Anthropic()
class Fix(BaseModel):
diagnosis: str # 5. what failed and why
code: str # 6. the fixed version
def check(code: str) -> tuple[bool, str]: # 3. external signal
open("solution.py", "w", encoding="utf-8").write(code)
r = subprocess.run(["pytest", "tests/test_solution.py", "-q", "-x"], capture_output=True, text=True, timeout=120)
return r.returncode == 0, (r.stdout + r.stderr)[-3000:]
def solve(task: str, max_attempts: int = 3) -> dict:
code = client.messages.create( # 2
model="claude-sonnet-5-5", max_tokens=8000,
system="Answer with the complete Python code for solution.py only.",
messages=[{"role": "user", "content": task}],
).content[-1].text
last_error = ""
for attempt in range(max_attempts):
ok, output = check(code)
if ok:
return {"code": code, "attempts": attempt + 1} # 7
if output == last_error:
break # same error again: stop
last_error = output
fix = client.messages.parse( # 5 and 6 in one call
model="claude-sonnet-5-5", max_tokens=8000,
system="Diagnose the failure before fixing. Do not modify the tests.",
messages=[{"role": "user", "content": f"Task:\n{task}\n\nCode:\n{code}\n\npytest output:\n{output}"}],
output_format=Fix,
).parsed_output
code = fix.code
return {"code": code, "attempts": max_attempts, "status": "unresolved", "last_error": last_error}What you gained: every fix starts from a real error, with the diagnosis recorded. What you lost: the “do not modify the tests” instruction needs a check in code too; the model may try.
TypeScript. Use this when the external signal is a rule check, such as the brand guide.
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const text = (m: Anthropic.Message) => m.content.find((b) => b.type === "text")?.text ?? "";
// 3. external signal in code: brand guide rules
function brandCheck(post: string): string[] {
const problems: string[] = [];
for (const term of BANNED_TERMS) if (post.toLowerCase().includes(term)) problems.push(`Banned term: "${term}"`);
if (post.length > 2200) problems.push(`It has ${post.length} characters; the limit is 2,200.`);
if (!/https:\/\/\S+/.test(post)) problems.push("Missing the link to the product page.");
return problems;
}
export async function writeWithReflection(brief: string, maxAttempts = 3) {
let post = text(await client.messages.create({
model: "claude-sonnet-5-5", max_tokens: 3000,
messages: [{ role: "user", content: `Write the post for this brief:\n${brief}` }],
}));
for (let i = 0; i < maxAttempts; i++) {
const problems = brandCheck(post);
if (!problems.length) return { post, attempts: i + 1 }; // 7
post = text(await client.messages.create({ // 5 and 6
model: "claude-sonnet-5-5", max_tokens: 3000,
messages: [{
role: "user",
content: `This post broke the guide:\n- ${problems.join("\n- ")}\n\nExplain the cause in one sentence and rewrite only what is needed. ` +
`Return only the final post.\n\n${post}`,
}],
}));
}
return { post, attempts: maxAttempts, status: "unresolved", problems: brandCheck(post) };
}What you gained: the signal is deterministic and cheap, and the model gets the exact list of what to change. What you lost: the cause explanation gets mixed into the text if the model ignores the instruction; use structured output if you need it separate.
Python + LangGraph. Use this when the test must be a node the model cannot skip.
from typing import Literal
from langchain.chat_models import init_chat_model
from langgraph.graph import END, START, StateGraph
from typing_extensions import TypedDict
class State(TypedDict):
task: str
attempt: str
signal: str
rounds: int
writer = init_chat_model("anthropic:claude-opus-5-5", temperature=0.2)
def draft(state: State) -> dict:
# 2 on the first turn; 5 and 6 after that, with the concrete error
reply = writer.invoke([
("system", "Fix the attempt using only the signal. Do not modify the tests."),
("human", f"{state['task']}\n\nAttempt:\n{state['attempt']}\n\nSignal:\n{state['signal']}"),
])
return {"attempt": reply.text, "rounds": state["rounds"] + 1}
def check(state: State) -> dict:
ok, log = run_tests(state["attempt"]) # 3. external signal
return {"signal": "ok" if ok else log}
def route(state: State) -> Literal["draft", "done"]:
# 4. code decides, not the model
if state["signal"] == "ok" or state["rounds"] >= 4:
return "done"
return "draft"
graph = StateGraph(State)
graph.add_node("draft", draft)
graph.add_node("check", check)
graph.add_edge(START, "draft")
graph.add_edge("draft", "check")
graph.add_conditional_edges("check", route, {"draft": "draft", "done": END})
app = graph.compile()run_tests returns the pytest log. On the first input, attempt and signal start empty.
What you gained: skipping the test is no longer a possible action. What you lost: reflection and the fix share one call; split them into two nodes if you want the diagnosis stored on its own.
| Construction | Who says it passed | On this page |
|---|---|---|
A for with pytest |
Code | Python and TypeScript tabs |
| A graph with a test node | The edge | LangGraph tab |
create_agent with the test as a tool |
The model, if it calls the tool | A fragile shape for this pattern |
Reflection is an AI design pattern for LLM apps and agents, in the Agent behavior group. Have the model critique its own result against an external signal and fix it.
In the real-world scenarios Content creation pipeline, Test generator, Automated PR reviewer, and Coding agent.