aiml
SearchMy learning
Menu

Lesson 7 of 9Module 2

Plan, execute and verify a multi-step task in LangGraph

Build a LangGraph plan, execute and verify graph that applies a review fix with a create_agent executor and grades it by running the project's tests.

Prerequisites

The graph agent and router from the first two lessons of this course, and pytest. Python 3.12 or later and a Gemini API key in GOOGLE_API_KEY, or Ollama with gemma4:e2b (set REVIEW_MODEL=gemma4:e2b).

By the end, you can

  • Split a task into plan, execute and verify nodes, with a structured plan from with_structured_output.
  • Run each plan step with a create_agent executor that has its own step limit.
  • Grade the run from the files on disk, including the project's own tests, and replan from the failed checks.
Runtime
Python 3.12+, a Gemini API key (or Ollama with gemma4:e2b)
Dependencies
langgraph 1.2.16, langchain 1.4.6, langchain-core 1.6.12, langchain-google-genai 4.4.2, pytest 9.1.1; langchain-ollama 1.1.0 for the local alternative

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

Progress is saved in this browser.

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.

graph.py · workspace.py · create_agent · gemini-3.8-flash · recorded in runs.json
task.jsonrequest, files,checksrequestStateGraph: plan, step, attempt, log, resultsplanwith_structured_outputGeminigemini-3.8-flashPlanexecute (each step)create_agent(model, tools)recursion_limit = step_limitstepsworkspace folderread_file,write_fileRefusedpath outside folderstep limit hitno report, step endssteps doneverify: run_checkspytest in the folder,unchanged, containsthe files on diskallpassed?resultsno: failed checkspassed / attempts spentENDrun_task derives the verdictdata flowreturnfailuredata flowreturnfailuretask.jsonrequest, files, checksplanstructuredGeministepsexecutecreate_agent per step,own step limiteditsworkspacesteplimit:no reportfiles on diskverifypytest, unchanged, linesfailed checkspassed /attempts spentEND: verdictrun_task reads the checks
The model plans and edits files. A separate verifier runs the project checks and either accepts the result or returns failures to the planner.

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.

Prepare the local labTerminal · bash
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r requirements-lock.txt

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

task.jsonSource · jsonDownload
{
 "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

workspace.pySource · pythonDownload
"""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 results

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

graph.pySource · pythonDownload
"""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"}

  • plan calls with_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.
  • execute runs one plan step with create_agent from LangChain: a prebuilt model-and-tools loop like the one in the first lesson. It gets its own recursion_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.
  • verify runs the checks. after_verify ends the run on a pass, or after MAX_ATTEMPTS plans, and otherwise goes back to plan.

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.

Terminal commandsTerminal · bash
python record.py
run-output.txt (the run with the shorter step limit)Recorded output · textDownload
== 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: fail

Full output

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

Terminal commandsTerminal · bash
python -m pytest -v test_graph.py
test-output.txtRecorded output · textDownload
test_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
test_graph.pySource · pythonDownload
"""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")
record.pySource · pythonDownload
"""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()
runs.json (the checks of the failing run)Recorded output · jsonDownload
[
 {
  "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
 }
]

Full recording: runs.json

With an executor limit of 8 steps, step 1 ended without editing client/http.py, and step 2 reported that it added the changelog line. What did the verify node conclude, and from what?

Choose one answer, then check your reasoning.

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

References

Official sources checked October 21, 2026, against langgraph 1.2.16, langchain 1.4.6 and langchain-core 1.6.12.

Written by Arjun Prabhulal.

Module 2

Durable workflows