An agent’s report of what it did is only a claim. The run is done when the files are right, and the way to know is to check them yourself: run the project’s tests, compare what must not change, look for the lines that must be there. In this lesson the code-review assistant applies the review decision on #415 to a small project folder. A LangGraph graph plans the edits, runs each one with an executor agent, and verifies the folder. In the recorded run with gemini-3.8-flash, the 16-step executor produced files that passed the independent checks. The planner requests a structured plan from the model, each execute step uses its own agent loop, and the verifier runs project tests and file checks without a model:
When you need this. The review assistant now edits code: change a default, add an override, update the changelog. Before merging, run checks against the resulting files. Keep those checks separate from the executor’s report that it finished.
1. Write the task as a request and checks
Download the complete lab, extract it and open a terminal in task-runner-verification/. The README lists its runtime and recorded examples.
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-lock.txtUse the extracted lab and its virtual environment. The task holds the starting files, the request in plain words, and the checks that decide the verdict:
{
"request": "Apply the review decision on pull request 415: set DEFAULT_TIMEOUT back to 10 in client/http.py, give Client.__init__ a timeout keyword argument that defaults to DEFAULT_TIMEOUT and is stored as self.timeout, and add the line '- #415 Add a per-client timeout override' under '## Unreleased' in CHANGELOG.md. Do not change the tests.",
"files": {
"client/http.py": "DEFAULT_TIMEOUT = 30\n\n\nclass Client:\n def __init__(self, base_url: str):\n self.base_url = base_url\n self.timeout = DEFAULT_TIMEOUT\n",
"tests/test_client.py": "from client.http import DEFAULT_TIMEOUT, Client\n\n\ndef test_default_timeout():\n assert DEFAULT_TIMEOUT == 10\n\n\ndef test_per_client_timeout():\n assert Client(\"https://api.example.test\", timeout=5).timeout == 5\n assert Client(\"https://api.example.test\").timeout == 10\n",
"CHANGELOG.md": "# Changelog\n\n## Unreleased\n\n## 2.4.0\n- #398 Stream large exports\n"
},
"checks": [
{
"path": "tests/test_client.py",
"pytest": true
},
{
"path": "tests/test_client.py",
"unchanged": true
},
{
"path": "CHANGELOG.md",
"contains": "## Unreleased\n- #415 Add a per-client timeout override"
},
{
"path": "CHANGELOG.md",
"contains": "## 2.4.0\n- #398 Stream large exports"
}
]
}The checks are written before the run, by the person who owns the task, and the model does not decide the verdict: the verifier runs the checks on the files. The checks are not secret. The test file the first check runs is in the workspace, where the executor’s read_file can open it, and after a failed attempt the planner is sent the failed checks so it can fix them. This lesson keeps them visible on purpose, because the test file is part of the project. If the checks must stay hidden, as in a held-out grading suite, keep them outside the folder the tools can read and send the planner only a pass or fail. The first runs the project’s own test file. The second says that test file must not change, because an executor could edit a failing test instead of fixing the implementation. The last two look for the new changelog line and for the release section that must survive.
2. Confine the tools and write the verifier
"""File tools confined to one workspace folder, and the checks that read the folder afterwards."""
import subprocess
import sys
from pathlib import Path
from langchain_core.tools import tool
def make_tools(root: Path) -> list:
def inside(path: str) -> Path | None:
target = (root / path).resolve()
return target if target.is_relative_to(root.resolve()) else None
@tool
def read_file(path: str) -> str:
"""Read a text file in the workspace, for example CHANGELOG.md."""
target = inside(path)
if target is None:
return f"Refused: {path} is outside the workspace."
return target.read_text() if target.is_file() else f"No file {path}."
@tool
def write_file(path: str, content: str) -> str:
"""Replace the whole content of a text file in the workspace, creating folders as needed."""
target = inside(path)
if target is None:
return f"Refused: {path} is outside the workspace."
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content)
return f"Wrote {len(content)} characters to {path}."
return [read_file, write_file]
def run_checks(root: Path, checks: list[dict], original: dict[str, str]) -> list[dict]:
"""Evaluate the task's own checks against the files on disk. Never asks the model."""
results = []
for check in checks:
path = root / check["path"]
text = path.read_text() if path.is_file() else None
if check.get("pytest"): # run the project's tests in the workspace
done = subprocess.run([sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider", check["path"]],
cwd=root, capture_output=True, text=True, timeout=60)
ok, detail = done.returncode == 0, done.stdout.strip().splitlines()[-1:]
elif check.get("unchanged"):
ok, detail = text == original[check["path"]], []
else:
ok, detail = text is not None and check["contains"] in text, []
results.append({**check, "passed": ok, **({"detail": detail[0]} if detail else {})})
return resultsmake_tools binds read_file and write_file to one folder, and refuses any path that resolves outside it, ../ included, with a message the model can read. run_checks is the verifier. It reads the folder and runs python -m pytest in it, with a timeout, without calling the model. The path check confines the read/write tools, not the Python process that runs the project tests. Those tests import the edited application code with the verifier process’s permissions; neither the workspace path nor the timeout provides a sandbox. The bundled fixture is local demonstration code. Use a separately isolated execution environment before running untrusted generated code.
A report that claims work not done. A scripted executor that only says “Done” gets a fail. Derive the verdict from the file checks instead of the report. Check: test_the_verdict_comes_from_the_files_not_the_report.
The tests edited to pass. A rewritten test file fails the unchanged check. Check that the files the task must not touch are unchanged. Check: test_editing_the_tests_to_pass_fails_the_unchanged_check.
A path outside the folder. write_file refuses ../escape.txt, and nothing is written. Resolve every path and check it against the workspace root. Check: test_a_path_outside_the_workspace_is_refused.
In the recorded 8-step run, one step ended without a report and another said “Added”, but the file checks failed. A pipeline that trusted only those reports could treat the task as complete.
The checks are only as strong as you write them. These prove the tests pass, the test file is unchanged and two lines are present. They don’t prove the code is good: a reviewer still reads the diff.
3. Build the plan, execute and verify graph
With the task and the verifier in place, the graph itself is short. """A plan, execute and verify graph in LangGraph. The verifier reads the files on disk, not the agent's report."""
import json
import operator
import os
from pathlib import Path
from typing import Annotated, Literal
from langchain.agents import create_agent
from langchain_core.messages import ToolMessage
from langchain_google_genai import ChatGoogleGenerativeAI
from langchain_ollama import ChatOllama
from langgraph.errors import GraphRecursionError
from langgraph.graph import END, START, StateGraph
from pydantic import BaseModel, Field
from typing_extensions import TypedDict
from workspace import make_tools, run_checks
MODEL = os.environ.get("REVIEW_MODEL", "gemini-3.8-flash") # REVIEW_MODEL=gemma4:e2b runs it on Ollama
MAX_ATTEMPTS = 2 # plans per task
STEP_LIMIT = 16 # graph steps the executor agent may take on one plan step
EXECUTE = """You carry out one step of a plan in a project folder. Read a file before you change it,
then write its complete new content. Reply with one sentence saying what you changed."""
class Step(BaseModel):
path: str = Field(description="The one file this step changes, relative to the project folder.")
change: str = Field(description="Exactly what to write or change in that file.")
class Plan(BaseModel):
steps: list[Step] = Field(description="One step per file, in order.")
class State(TypedDict, total=False):
request: str
workspace: str
files: dict[str, str] # the starting files, so a check can tell what changed
checks: list[dict]
plan: list[dict]
step: int
attempt: int
log: Annotated[list[dict], operator.add] # every tool call and every executor report
results: list[dict]
def chat_model():
if MODEL.startswith("gemini"):
return ChatGoogleGenerativeAI(model=MODEL) # reads GOOGLE_API_KEY from the environment
return ChatOllama(model=MODEL, temperature=0, reasoning=False)
def build_graph(model=None, step_limit: int = STEP_LIMIT, planner=None):
model = model or chat_model()
planner = planner or model.with_structured_output(Plan)
def plan(state: State) -> dict:
prompt = f"Plan this task as file edits:\n{state['request']}"
failed = [r for r in state.get("results", []) if not r["passed"]]
if failed:
prompt += f"\nThe last attempt failed these checks on the files; fix them: {json.dumps(failed)}"
steps = [s.model_dump() for s in planner.invoke(prompt).steps]
return {"plan": steps, "step": 0, "attempt": state.get("attempt", 0) + 1}
def execute(state: State) -> dict:
agent = create_agent(model, make_tools(Path(state["workspace"])), system_prompt=EXECUTE)
step = state["plan"][state["step"]]
ask = f"Task: {state['request']}\nDo only this step. File: {step['path']}\nChange: {step['change']}"
messages, report = [], None
try:
for values in agent.stream({"messages": [("user", ask)]}, {"recursion_limit": step_limit}, stream_mode="values"):
messages = values["messages"]
report = messages[-1].text
except GraphRecursionError: # the step did not finish; the verifier still checks the files
report = f"(no report: stopped at the step limit of {step_limit})"
where = {"attempt": state["attempt"], "step": state["step"] + 1}
calls = {c["id"]: c for m in messages for c in getattr(m, "tool_calls", [])}
log = [{**where, "tool": m.name, "path": calls[m.tool_call_id]["args"].get("path"), "result": m.text}
for m in messages if isinstance(m, ToolMessage)]
return {"step": state["step"] + 1, "log": [*log, {**where, "report": report}]}
def verify(state: State) -> dict:
return {"results": run_checks(Path(state["workspace"]), state["checks"], state["files"])}
def next_step(state: State) -> Literal["execute", "verify"]:
return "execute" if state["step"] < len(state["plan"]) else "verify"
def after_verify(state: State) -> Literal["plan", "__end__"]:
passed = all(r["passed"] for r in state["results"])
return END if passed or state["attempt"] >= MAX_ATTEMPTS else "plan"
graph = StateGraph(State)
graph.add_node("plan", plan)
graph.add_node("execute", execute)
graph.add_node("verify", verify)
graph.add_edge(START, "plan")
graph.add_conditional_edges("plan", next_step) # an empty plan goes straight to verify
graph.add_conditional_edges("execute", next_step)
graph.add_conditional_edges("verify", after_verify)
return graph.compile()
def run_task(graph, task: dict, folder: Path) -> dict:
for name, text in task["files"].items():
(folder / name).parent.mkdir(parents=True, exist_ok=True)
(folder / name).write_text(text)
state = graph.invoke({"request": task["request"], "workspace": str(folder), "files": task["files"],
"checks": task["checks"]},
{"recursion_limit": 40})
return {**state, "verdict": "pass" if all(r["passed"] for r in state["results"]) else "fail"}
plancallswith_structured_output(Plan), so the plan arrives as validated Pydantic objects, one step per file, not as prose to parse. On a second attempt the prompt includes the checks that failed.executeruns one plan step withcreate_agentfrom LangChain: a prebuilt model-and-tools loop like the one in the first lesson. It gets its ownrecursion_limit,STEP_LIMIT, so one step can’t use up the whole run. If the limit is hit, the step ends with no report, and the graph goes on.verifyruns the checks.after_verifyends the run on a pass, or afterMAX_ATTEMPTSplans, and otherwise goes back toplan.
Why not one agent with all the tools and the whole request? It would work on a task this size. The split buys two things: each step has a bound you can set, and the verdict comes from a node that has no model in it.
The executor runs out of steps. At 8 steps, step 1 ended with no report and no edit; every report the run did write described a success, and the verdict was fail. Give each step a limit sized for exploration, and let verify decide. Check: test_an_executor_that_runs_out_of_steps_still_reaches_verify, test_recorded_runs_failed_at_8_steps_and_passed_at_16.
A replan with the same plan. The failed checks went into the second plan’s prompt, and it planned the same edits again, because the cause was the step limit, not the plan. Inspect why execution stopped before replanning; repeating this plan did not fix the step limit. Check: test_the_failed_checks_go_into_the_next_plan.
4. Run it on Gemini
record.py runs the task twice, with an executor limit of 8 steps and of 16, each in a fresh temporary folder.
python record.py== step_limit=8 attempts: 2 last plan:
1. client/http.py: Set DEFAULT_TIMEOUT back to 10. Update Client.__init__ to accept a timeout keyword argumen
2. CHANGELOG.md: Under the '## Unreleased' section header, add the line '- #415 Add a per-client timeout ov
attempt 1 step 1 read_file client/http.py -> DEFAULT_TIMEOUT = 30
attempt 1 step 1 read_file CHANGELOG.md -> # Changelog
attempt 1 step 1 read_file tests/test_http.py -> No file tests/test_http.py.
attempt 1 step 1 read_file tests/test_client.py -> from client.http import DEFAULT_TIMEOUT, Cli
attempt 1 step 1 report: (no report: stopped at the step limit of 8)
attempt 1 step 2 read_file CHANGELOG.md -> # Changelog
attempt 1 step 2 write_file CHANGELOG.md -> Wrote 106 characters to CHANGELOG.md.
attempt 1 step 2 report: Added the entry `- #415 Add a per-client timeout override` under the `## Unreleased` heading in CHANGE
attempt 2 step 1 read_file client/http.py -> DEFAULT_TIMEOUT = 30
attempt 2 step 1 read_file tests/test_http.py -> No file tests/test_http.py.
attempt 2 step 1 read_file test_client.py -> No file test_client.py.
attempt 2 step 1 read_file tests/test_client.py -> from client.http import DEFAULT_TIMEOUT, Cli
attempt 2 step 1 report: (no report: stopped at the step limit of 8)
attempt 2 step 2 read_file CHANGELOG.md -> # Changelog
attempt 2 step 2 write_file CHANGELOG.md -> Wrote 106 characters to CHANGELOG.md.
attempt 2 step 2 report: Added the line `- #415 Add a per-client timeout override` under the `## Unreleased` section header in
FAIL tests/test_client.py pytest 2 failed in 0.05s
PASS tests/test_client.py unchanged
PASS CHANGELOG.md "## Unreleased\n- #415 Add a per-client timeout override"
PASS CHANGELOG.md "## 2.4.0\n- #398 Stream large exports"
verdict: failHow it works
The outer graph’s state carries the plan, the step index, the attempt and a log. Each execute run builds a fresh executor, so no messages leak from one step to the next: the plan and the files are the only things the steps share. The log field has an operator.add reducer, so every step appends its tool calls and its report, and the recording holds every write that happened.
With the 8-step limit, the executor spent step 1 reading: 4 read_file calls on the first attempt, including files that do not exist, before the limit stopped it with client/http.py unchanged. Step 2 wrote the changelog and reported success. The verifier ran the tests: 2 failed in 0.05s. The second attempt planned the same two edits and failed the same way. With 16 steps, the executor read 6 files, then wrote client/http.py, and the tests passed: 2 passed in 0.01s.
Test the runner without a model
test_graph.py replaces the planner with a fixed plan and the executor’s model with scripted replies, and runs the real graph, tools and checks, pytest included, in a temporary folder.
python -m pytest -v test_graph.pytest_graph.py::test_a_correct_edit_passes_every_check PASSED [ 12%]
test_graph.py::test_the_verdict_comes_from_the_files_not_the_report PASSED [ 25%]
test_graph.py::test_editing_the_tests_to_pass_fails_the_unchanged_check PASSED [ 37%]
test_graph.py::test_the_failed_checks_go_into_the_next_plan PASSED [ 50%]
test_graph.py::test_an_executor_that_runs_out_of_steps_still_reaches_verify PASSED [ 62%]
test_graph.py::test_the_checks_are_not_hidden_from_the_model PASSED [ 75%]
test_graph.py::test_a_path_outside_the_workspace_is_refused PASSED [ 87%]
test_graph.py::test_recorded_runs_failed_at_8_steps_and_passed_at_16 PASSED [100%]Running teardown with pytest sessionfinish...
============================== 8 passed in 23.46s ==============================All 8 tests pass.
Read the test and recording scripts
"""Offline tests: a scripted planner and executor replace Gemini; the checks run on real files."""
import json
from itertools import count
from pathlib import Path
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.messages import AIMessage
from langchain_core.runnables import RunnableLambda
import graph
from workspace import make_tools
TASK = json.loads(Path(__file__).with_name("task.json").read_text())
RUN = json.loads(Path(__file__).with_name("runs.json").read_text())
FIXED = TASK["files"]["client/http.py"].replace("= 30", "= 10").replace(
"base_url: str):", "base_url: str, timeout=DEFAULT_TIMEOUT):").replace("= DEFAULT_TIMEOUT\n", "= timeout\n")
CHANGELOG = TASK["files"]["CHANGELOG.md"].replace("## Unreleased\n", "## Unreleased\n- #415 Add a per-client timeout override\n")
PLAN = graph.Plan(steps=[graph.Step(path="client/http.py", change="fix"), graph.Step(path="CHANGELOG.md", change="add")])
ids = count()
class Scripted(GenericFakeChatModel):
def bind_tools(self, tools, **kwargs):
return self
def write(path, content):
return AIMessage("", tool_calls=[{"name": "write_file", "args": {"path": path, "content": content}, "id": f"w{next(ids)}"}])
def run(tmp_path, replies, plans=None):
prompts = []
planner = RunnableLambda(lambda prompt: prompts.append(prompt) or PLAN)
g = graph.build_graph(Scripted(messages=iter(replies)), step_limit=8, planner=planner)
return graph.run_task(g, TASK, tmp_path), prompts
def test_a_correct_edit_passes_every_check(tmp_path):
state, _ = run(tmp_path, [write("client/http.py", FIXED), AIMessage("Fixed http.py."),
write("CHANGELOG.md", CHANGELOG), AIMessage("Added the line.")])
assert state["verdict"] == "pass" and state["attempt"] == 1
def test_the_verdict_comes_from_the_files_not_the_report(tmp_path):
claims = [AIMessage("Done: DEFAULT_TIMEOUT is 10 and Client takes a timeout.")] * 4
state, _ = run(tmp_path, claims)
assert state["verdict"] == "fail"
assert all("Done" in e["report"] for e in state["log"])
def test_editing_the_tests_to_pass_fails_the_unchanged_check(tmp_path):
easier = TASK["files"]["tests/test_client.py"].replace("== 10", "== 30")
replies = [write("tests/test_client.py", easier), AIMessage("Tests pass now."), write("CHANGELOG.md", CHANGELOG), AIMessage("ok")] * 2
state, _ = run(tmp_path, replies)
by_kind = {("pytest" if r.get("pytest") else "unchanged" if r.get("unchanged") else r["contains"]): r["passed"]
for r in state["results"]}
assert state["verdict"] == "fail" and by_kind["unchanged"] is False
def test_the_failed_checks_go_into_the_next_plan(tmp_path):
state, prompts = run(tmp_path, [AIMessage("nothing")] * 4)
assert state["attempt"] == graph.MAX_ATTEMPTS
assert "failed these checks" not in prompts[0] and '"pytest": true' in prompts[1]
def test_an_executor_that_runs_out_of_steps_still_reaches_verify(tmp_path):
reads = (AIMessage("", tool_calls=[{"name": "read_file", "args": {"path": "CHANGELOG.md"}, "id": f"r{n}"}]) for n in count())
state, _ = run(tmp_path, reads)
assert state["results"] and state["verdict"] == "fail"
assert "stopped at the step limit" in state["log"][-1]["report"]
def test_the_checks_are_not_hidden_from_the_model(tmp_path):
# The verdict is the verifier's, but the checks are not secret: the test file the pytest check runs is in the
# workspace the executor reads. test_the_failed_checks_go_into_the_next_plan covers the replanning prompt.
for name, text in TASK["files"].items():
(tmp_path / name).parent.mkdir(parents=True, exist_ok=True)
(tmp_path / name).write_text(text)
read_file, _ = make_tools(tmp_path)
pytest_check = next(c for c in TASK["checks"] if c.get("pytest"))
assert read_file.invoke({"path": pytest_check["path"]}) == TASK["files"][pytest_check["path"]]
def test_a_path_outside_the_workspace_is_refused(tmp_path):
read_file, write_file = make_tools(tmp_path)
assert write_file.invoke({"path": "../escape.txt", "content": "x"}).startswith("Refused")
assert not (tmp_path.parent / "escape.txt").exists()
def test_recorded_runs_failed_at_8_steps_and_passed_at_16():
short, full = RUN["runs"]
assert (short["step_limit"], short["verdict"], short["attempts"]) == (8, "fail", 2)
assert (full["step_limit"], full["verdict"]) == (graph.STEP_LIMIT, "pass")"""Run the review-fix task on Gemini with two executor step limits; save plans, tool calls, reports and checks."""
import json
import tempfile
from pathlib import Path
from graph import MODEL, STEP_LIMIT, build_graph, run_task
def run(task: dict, step_limit: int) -> dict:
with tempfile.TemporaryDirectory() as folder:
state = run_task(build_graph(step_limit=step_limit), task, Path(folder))
files = {p.relative_to(folder).as_posix(): p.read_text() for p in sorted(Path(folder).rglob("*"))
if p.is_file() and "__pycache__" not in p.parts}
return {"step_limit": step_limit, "attempts": state["attempt"], "plan": state["plan"], "log": state["log"],
"results": state["results"], "verdict": state["verdict"], "files": files}
def main() -> None:
task = json.loads(Path("task.json").read_text())
runs = [run(task, 8), run(task, STEP_LIMIT)]
Path("runs.json").write_text(json.dumps({"model": MODEL, "runs": runs}, indent=1) + "\n")
for r in runs:
print(f"== step_limit={r['step_limit']} attempts: {r['attempts']} last plan:")
for i, step in enumerate(r["plan"], 1):
print(f" {i}. {step['path']}: {step['change'][:90]}")
for e in r["log"]:
what = f"report: {e['report']}" if "report" in e else f"{e['tool']:<10} {e['path']} -> {e['result'].splitlines()[0][:44]}"
print(f" attempt {e['attempt']} step {e['step']} {what[:110]}")
for c in r["results"]:
kind = "pytest" if c.get("pytest") else "unchanged" if c.get("unchanged") else json.dumps(c["contains"])
print(f" {'PASS' if c['passed'] else 'FAIL'} {c['path']} {kind} {c.get('detail', '')}".rstrip())
print(" verdict:", r["verdict"])
if __name__ == "__main__":
main()[
{
"path": "tests/test_client.py",
"pytest": true,
"passed": false,
"detail": "2 failed in 0.05s"
},
{
"path": "tests/test_client.py",
"unchanged": true,
"passed": true
},
{
"path": "CHANGELOG.md",
"contains": "## Unreleased\n- #415 Add a per-client timeout override",
"passed": true
},
{
"path": "CHANGELOG.md",
"contains": "## 2.4.0\n- #398 Stream large exports",
"passed": true
}
]Read the explanation
Grade outcomes from the system's state, with checks written before the run, not from the agent's account of it.
What’s next
- Add human-in-the-loop interrupts: pause the graph so a person approves a step before it runs.
References
Official sources checked October 21, 2026, against langgraph 1.2.16, langchain 1.4.6 and langchain-core 1.6.12.
- LangGraph: Workflows and agents: the orchestrator-worker and evaluator-optimizer patterns that this graph combines.
- LangChain: Agents:
create_agent, its tools and system prompt. - LangChain: Structured output:
with_structured_outputwith a Pydantic model. - LangGraph: Graph API: conditional edges, reducers and
recursion_limit. - Gemini API: Models: the
gemini-3.8-flashmodel ID.