ap.Abhishek Patel

Search

Search notes, essays, projects, the library and the AI course. Use arrow keys to choose and Enter to open.

Project 6 of 6 420 min

Project: A Claude Code-like coding agent

Ship a LangGraph coding agent that explores a repo, makes exact-match edits, runs allowlisted commands in a Docker sandbox, stops only when tests pass, asks before risky actions and compacts its context for long sessions.

Words in this lessonCoding agent · Sandbox · Docker · Command allowlist · Exact-match edit · Context management · Interrupt · Context window
Coding agent
An AI agent that reads a codebase, edits files and runs commands such as tests until a programming task is done. Like a new developer who can only work in a locked practice room and must show passing tests before going home.
Sandbox
An isolated environment where code or an agent can run without reaching your files, secrets or network. Like a test kitchen with its own ingredients and a locked door, so a failed experiment never reaches the main bakery.
Docker
A tool that packages software with everything it needs so it runs the same way on any machine. Like shipping a sealed, ready-to-plug-in kitchen instead of a recipe and hoping the other kitchen matches.
Command allowlist
A list of the only programs an agent is permitted to run; everything else is refused. Like a guest list at the door: if your name is not on it, you do not get in, however politely you ask.
Exact-match edit
Changing a file by giving the exact text to find and the text to put in its place, rather than rewriting the whole file. Like correcting one word in a printed recipe with correction tape instead of rewriting the whole card by hand.
Context management
Keeping what an agent sends to the model small and relevant, so long sessions stay affordable and accurate. Like clearing a workbench as you go and keeping a notebook of what you have done, instead of piling every offcut on the bench.
Interrupt
A LangGraph call that pauses a run in the middle and waits, for as long as needed, for a person to respond. Like a cashier pressing the "call manager" button and leaving the sale open on the till until the manager arrives.
Context window
The maximum amount of text, counted in tokens, that a model can consider at once. Like the size of a baker's worktable: everything must fit on it, and whatever falls off is forgotten.
On this page
  1. The brief
  2. What you will build
  3. Architecture
  4. Tech stack
  5. Build it, milestone by milestone
  6. 1. Build the Docker sandbox
  7. 2. Write the read tools: list_files, read_file, search
  8. 3. Add edit_file with exact-match replace
  9. 4. Add run_command with an allowlist and timeouts
  10. 5. Wire the agent loop as a LangGraph graph
  11. 6. Make passing tests the stop condition
  12. 7. Gate risky actions with human approval
  13. 8. Manage context for long sessions
  14. Done means done
  15. Stretch goals
  16. Common pitfalls
  17. Show it off

There is a red mark on Crumb’s GitHub repo that everyone has learned to ignore. A test called test_bulk_discount_applies_at_ten_items has been failing for two weeks. Corporate customers ordering ten or more brownie boxes for Diwali gifts are supposed to get 10% off. The test says they do not. Nobody has had an hour to look.

“I don’t want you to fix it,” Priya told Arjun. “I want you to build the thing that fixes it.”

She meant a coding agent: a program like Claude Code that reads a codebase, finds the problem, edits files, runs the tests, and keeps going until they pass. “You already know every piece,” she said. “An agent loop from Module 13. Tools with tight rules. A graph with a verify step, a pause for approval and a checkpointer, from this module. The only new thing is that the tools touch real code, so they have to be careful. That’s what this project is really about.”

Arjun is building it for Crumb’s repo, but you can point yours at any Python project with a test suite. Priya will review each milestone.

Important

What you will ship: a command-line coding agent, built as a LangGraph graph, that explores a repository, makes exact edits, runs commands only inside a locked-down Docker sandbox, keeps going until the tests pass, asks a human before risky actions, and manages its own context so it can work through long sessions.

The brief

Given a task in plain English (“make the failing pricing test pass without changing the test”), the agent must:

  1. Understand the codebase. List files, read them in windows with line numbers, and search for symbols.
  2. Make edits safely. Change files with exact-match replacements, never by rewriting whole files from memory.
  3. Run commands in a sandbox. Tests and inspection commands run inside a Docker container with no network, limited memory and CPU, and a time limit. Only allowlisted programs run.
  4. Iterate until done. The stop condition is not “the model says it is finished”. It is “the test suite passes”, with a maximum number of attempts.
  5. Ask before risky actions. Deleting files and committing pause for a human yes or no, and the pause survives a restart.
  6. Last through long sessions. Tool outputs are clipped, files are read in windows, old conversation is summarised when it grows too large, and the agent keeps a progress-notes file.

Out of scope: pushing to GitHub, opening pull requests, multi-language support, a web interface. Those are stretch goals.

What you will build

Must have:

  • A Docker sandbox with your repo mounted at /workspace, no network, 1 GB memory, 1 CPU.
  • Tools: list_files, read_file, search, edit_file, run_command, update_notes, delete_file, commit_changes.
  • Path safety: no tool can touch a file outside the repo.
  • A LangGraph graph: agent, tools, verify, compact.
  • Tests as the stop condition, with a cap of three verify attempts.
  • Human approval via interrupt for delete_file and commit_changes.
  • A checkpointer, so a session can resume.
  • A CLI that streams what the agent does and prompts you for approvals.

Nice to have:

  • The MongoDB checkpointer from the checkpointing lesson, so sessions survive a laptop restart.
  • A cost meter that prints token usage per session.
  • A --dry-run flag that refuses all edits.

Architecture

flowchart LR
    U["You (CLI)"] --> C[compact]
    C --> A[agent]
    A -->|"tool calls"| T[tools]
    T --> C
    A -->|"no tool calls"| V[verify]
    V -->|"tests fail"| C
    V -->|"tests pass"| E([END])
    T --> S["Docker sandbox"]
    V --> S

You (CLI). A small script that takes the task, streams each step of the graph to your terminal, and when the graph pauses for approval, asks you and resumes it. It holds no state of its own; all state lives in the graph’s checkpoints.

compact. Runs before every model call. Usually it does nothing. When the conversation grows past a size threshold, it asks the model to summarise the older messages, stores the summary in state, and removes those messages. It never separates a tool request from its result.

agent. Claude, with the tools bound and a system prompt describing the workflow. It sees the task, any summary, its progress notes and the recent messages, and either asks for tools or stops and summarises what it did.

tools. LangGraph’s prebuilt ToolNode. It runs whatever the agent asked for. Read and edit tools work on the repo folder directly (it is the same folder that is mounted into the sandbox). run_command executes inside the sandbox. Risky tools call interrupt and wait for you.

verify. Plain Python, no model. It runs the test command in the sandbox. Pass: the run ends. Fail: the failure output becomes a new message to the agent and the loop continues, up to three attempts.

Docker sandbox. A container that can see only the repo, has no network, and has hard limits on memory, CPU and time. Anything the model runs, runs here.

The checkpointer is not drawn because it touches every step: after each node, the full state is saved under the session’s thread ID.

ClipThe agent loop1/7
ThinkActObserveLoop 1
  1. Think: a customer says the cake arrived squashed. I should check the order.
Think: decide what to do next.

Tech stack

Piece Tool Why
Language Python 3.12, uv Same as the rest of the course
Model Claude (claude-opus-5-5) via langchain-anthropic Strong at reading and editing code; works with bind_tools
Agent graph LangGraph (StateGraph, ToolNode, tools_condition) Explicit loop, verify step, interrupts, checkpoints
Sandbox Docker Isolates everything the model runs from your machine
Checkpoints InMemorySaver, or MongoDBSaver Resume sessions; required for interrupt
Secrets python-dotenv Keys in .env, never in code

Build it, milestone by milestone

Set up the project:

uv init crumb-coder && cd crumb-coder
uv add langgraph langchain-anthropic python-dotenv

Put ANTHROPIC_API_KEY=... in .env. If you do not have a repo with a failing test handy, make a practice one. Create repo/pricing/__init__.py (empty), repo/requirements.txt containing pytest, and these two files:

# repo/pricing/order_pricing.py
BULK_THRESHOLD = 10      # items
BULK_DISCOUNT = 0.10     # 10% off

def order_total(unit_price: int, quantity: int) -> int:
    """Total in rupees, with the bulk discount for large orders."""
    subtotal = unit_price * quantity
    if quantity > BULK_THRESHOLD:
        subtotal = round(subtotal * (1 - BULK_DISCOUNT))
    return subtotal
# repo/tests/test_order_pricing.py
from pricing.order_pricing import order_total

def test_small_order():
    assert order_total(300, 2) == 600

def test_bulk_discount_applies_at_ten_items():
    assert order_total(300, 10) == 2700

Then run git init inside repo/ and commit, so you can always see and undo what the agent changed with git diff and git checkout.

1. Build the Docker sandbox

Why. The agent will run commands that a model chose. Even without bad intent, a model can run rm -rf on the wrong folder, start an infinite loop, or try to download something. And code in the repo might be hostile, for example a file containing instructions aimed at the model (prompt injection, which you met in Module 13). The sandbox makes the worst case “the container gets messed up” instead of “my laptop gets messed up”.

The simplest version uses the stock Python image:

docker run -d --name crumb-sandbox --network none --memory 1g --cpus 1 \
  -v "$PWD/repo:/workspace" -w /workspace python:3.12-slim sleep infinity

Every flag matters. -d runs it in the background. --network none means no internet, so nothing can be downloaded or sent out. --memory 1g --cpus 1 caps resources. -v "$PWD/repo:/workspace" mounts only your repo folder, nothing else from your machine. -w /workspace makes it the working directory. sleep infinity keeps the container alive so you can run commands in it with docker exec.

There is a catch: the stock image has no pytest, and with no network you cannot install it later. So build an image with the repo’s dependencies first, then run that with the network off:

# Dockerfile.sandbox
FROM python:3.12-slim
COPY repo/requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt
WORKDIR /workspace
docker build -t crumb-sandbox-img -f Dockerfile.sandbox .
docker rm -f crumb-sandbox      # remove the stock one if you started it
docker run -d --name crumb-sandbox --network none --memory 1g --cpus 1 \
  -v "$PWD/repo:/workspace" -w /workspace crumb-sandbox-img sleep infinity

Now the Python helper that every other milestone uses:

# sandbox.py
import subprocess

SANDBOX = "crumb-sandbox"

def clip(text: str, limit: int = 4000) -> str:
    """Keep the head and tail of long output; the middle is usually noise."""
    if len(text) <= limit:
        return text
    half = limit // 2
    return f"{text[:half]}\n... [{len(text) - limit} characters clipped] ...\n{text[-half:]}"

def sandbox_exec(command: str, timeout: int = 60) -> tuple[int, str]:
    """Run a shell command inside the sandbox. Returns (exit_code, clipped_output)."""
    try:
        proc = subprocess.run(
            ["docker", "exec", SANDBOX, "timeout", str(timeout), "sh", "-c", command],
            capture_output=True, text=True, timeout=timeout + 10,
        )
    except subprocess.TimeoutExpired:
        return 124, f"[timed out after {timeout}s]"
    output = proc.stdout + proc.stderr
    if proc.returncode == 124:          # the exit code `timeout` uses
        output += f"\n[timed out after {timeout}s]"
    return proc.returncode, clip(output)

There are two timeouts on purpose. timeout 60 inside the container kills the command itself. The subprocess timeout outside is a backstop in case docker exec hangs.

What working looks like. uv run python -c "from sandbox import sandbox_exec; print(sandbox_exec('python -m pytest -q'))" prints exit code 1 and a summary like 1 failed, 1 passed, including assert 3000 == 2700. And sandbox_exec('python -m pip download requests') fails with a network error, which is exactly what you want.

Most likely bug. Running docker run from the wrong folder, so $PWD/repo points at nothing and the container sees an empty /workspace. Run docker exec crumb-sandbox ls to check.

Stuck? Hint

If docker exec says the container is not running, check docker ps -a. A container whose main command exits stops immediately; sleep infinity is what keeps it up. If tests fail with ModuleNotFoundError: pricing, run them as python -m pytest, which puts the current folder (/workspace) on Python’s import path. On Docker Desktop for Mac or Windows, make sure your project folder is in a location Docker is allowed to share.

Why. A coding agent spends most of its time reading. Good read tools are what separate an agent that understands a codebase from one that guesses. Two rules shape them. First, never return unbounded output: a 3,000-line file dumped into the context is slow, expensive and buries the useful part. Second, never let a path escape the repo: ../../.ssh/id_rsa is a valid relative path.

# tools.py
import subprocess
from pathlib import Path

from langchain_core.tools import tool
from sandbox import SANDBOX, clip

REPO = Path("repo").resolve()
SKIP = {".git", "__pycache__", ".venv", "node_modules", ".pytest_cache"}

def safe_path(rel_path: str) -> Path:
    """Resolve a repo-relative path and refuse anything outside the repo."""
    path = (REPO / rel_path).resolve()
    if not path.is_relative_to(REPO):
        raise ValueError(f"'{rel_path}' is outside the repository.")
    return path

@tool
def list_files(directory: str = ".") -> str:
    """List files under a directory of the repo, recursively (max 200).
    Paths are relative to the repo root."""
    try:
        root = safe_path(directory)
    except ValueError as error:
        return f"Error: {error}"
    files = [str(p.relative_to(REPO)) for p in sorted(root.rglob("*"))
             if p.is_file() and not SKIP & set(p.relative_to(REPO).parts)]
    more = f"\n... and {len(files) - 200} more" if len(files) > 200 else ""
    return "\n".join(files[:200]) + more or "(no files)"

@tool
def read_file(path: str, offset: int = 1, limit: int = 200) -> str:
    """Read a text file from the repo with line numbers.
    offset is the first line to show (1-based); limit is how many lines.
    For long files, read in windows instead of all at once."""
    try:
        p = safe_path(path)
    except ValueError as error:
        return f"Error: {error}"
    if not p.is_file():
        return f"Error: '{path}' is not a file. Use list_files to see what exists."
    lines = p.read_text(errors="replace").splitlines()
    window = lines[offset - 1: offset - 1 + limit]
    numbered = [f"{n:>5}  {line}" for n, line in enumerate(window, start=offset)]
    end = offset + len(window) - 1
    return "\n".join(numbered) + f"\n[lines {offset}-{end} of {len(lines)}]"

@tool
def search(pattern: str) -> str:
    """Search the repo for a regular expression, like 'def order_total' or
    'BULK_'. Returns matching lines as path:line:text (max 60)."""
    proc = subprocess.run(
        ["docker", "exec", SANDBOX, "grep", "-rnIE", "--exclude-dir=.git",
         "-e", pattern, "."],
        capture_output=True, text=True, timeout=30,
    )
    lines = proc.stdout.splitlines()
    if not lines:
        return f"No matches for {pattern!r}."
    more = f"\n... {len(lines) - 60} more matches" if len(lines) > 60 else ""
    return clip("\n".join(lines[:60]) + more)

search runs grep inside the sandbox, and passes the pattern as a separate argument (no sh -c), so a pattern full of quotes or semicolons is just a pattern, never a command.

Line numbers in read_file are not decoration. They let the model say “line 8” when reasoning, and let it ask for offset=180, limit=40 instead of re-reading everything. The footer (lines 1-200 of 3120) tells it there is more.

What working looks like. read_file.invoke({"path": "pricing/order_pricing.py"}) returns numbered lines ending [lines 1-9 of 9]. read_file.invoke({"path": "../.env"}) returns an error, not your API key. search.invoke({"pattern": "BULK_"}) returns lines like ./pricing/order_pricing.py:1:BULK_THRESHOLD = 10.

Most likely bug. Checking the path before resolving it. "pricing/../../.env" contains no suspicious prefix, but resolves outside the repo. Always resolve() first, then check with is_relative_to.

Stuck? Hint

Path.is_relative_to exists from Python 3.9. If rglob is slow on a big repo, it is probably walking .venv or node_modules; the SKIP check filters them out of the results but still walks them, so for large repos switch to os.walk and prune those folders from the walk. Call tools in tests with .invoke({...}), since @tool turns the function into a tool object.

3. Add edit_file with exact-match replace

Why. The obvious design, “write this whole file”, is the worst one. To rewrite a 400-line file, the model must reproduce 399 unchanged lines perfectly from memory. It will drop a line, change an indent or “tidy” something, and the diff will be full of accidental changes. The design Claude Code and similar tools use is exact-match replace: give the exact text to find and the text to put in its place. The model only writes what changes.

The rule that makes it safe: the old text must appear exactly once. Zero matches means the model misremembered the file. Two or more means the edit is ambiguous. In both cases, refuse and say how to fix it.

# tools.py (continued)
@tool
def edit_file(path: str, old_string: str, new_string: str) -> str:
    """Replace one exact snippet in a repo file. old_string must match the file
    exactly (including indentation) and appear exactly once; include a few
    surrounding lines if needed to make it unique. To create a new file, pass
    an empty old_string."""
    try:
        p = safe_path(path)
    except ValueError as error:
        return f"Error: {error}"
    if old_string == "":
        if p.exists():
            return f"Error: '{path}' already exists. Pass the text to replace."
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text(new_string)
        return f"Created {path}."
    if not p.is_file():
        return f"Error: '{path}' does not exist."
    text = p.read_text()
    count = text.count(old_string)
    if count == 0:
        return (f"Error: old_string was not found in {path}. Re-read the file with "
                "read_file and copy the text exactly, including indentation.")
    if count > 1:
        return (f"Error: old_string appears {count} times in {path}. Add more "
                "surrounding lines so it matches exactly once.")
    p.write_text(text.replace(old_string, new_string, 1))
    return f"Edited {path}."

Every error message is written to the model: what went wrong and what to do next. That is how tool errors become recoveries instead of loops.

What working looks like. Replacing " if quantity > BULK_THRESHOLD:" with " if quantity >= BULK_THRESHOLD:" succeeds and git diff in repo/ shows exactly one changed line. Replacing "subtotal" fails with “appears 4 times”, and replacing text that is not in the file fails with “not found”.

Most likely bug. Line numbers sneaking into old_string. The model sometimes copies 8 if quantity > ... straight from read_file output. Say clearly in the system prompt (milestone 5) that line numbers are not part of the file. If it keeps happening, make the error message mention it.

Stuck? Hint

Watch out for Windows line endings (\r\n) in repos edited on Windows: the model sends \n, the file has \r\n, and nothing matches. Either normalise on read and write, or detect it and say so in the error. If you want a safety net, save a copy of the old file content in a dictionary before writing, so you can offer an undo_last_edit tool later.

4. Add run_command with an allowlist and timeouts

Why. The agent needs to run tests and look around (python -m pytest -q, python -c "..."). The sandbox already limits the damage a command can do. An allowlist (a list of the only programs permitted) adds a second layer: it keeps the agent on the narrow path you expect, and makes surprises visible as refusals in the log rather than as strange behaviour. This is least privilege: give the agent exactly the power its job needs.

# tools.py (continued)
import shlex
from sandbox import sandbox_exec

ALLOWED = {"python", "pytest", "ls", "cat", "head", "tail", "wc", "grep", "find"}
FORBIDDEN = [";", "&&", "||", "|", ">", "<", "`", "$(", "\n"]

@tool
def run_command(command: str) -> str:
    """Run ONE command in the sandbox (repo at /workspace, no network, 60 s limit).
    Allowed programs: python, pytest, ls, cat, head, tail, wc, grep, find.
    No pipes, redirects, ; or &&. Example: 'python -m pytest -q'."""
    for bad in FORBIDDEN:
        if bad in command:
            return f"Refused: '{bad}' is not allowed. Run one simple command at a time."
    try:
        parts = shlex.split(command)
    except ValueError as error:
        return f"Refused: could not parse the command ({error})."
    if not parts or parts[0] not in ALLOWED:
        return f"Refused: only these programs are allowed: {sorted(ALLOWED)}."
    code, output = sandbox_exec(command, timeout=60)
    return f"exit code {code}\n{output}"

shlex.split splits a command the way a shell would (respecting quotes), so parts[0] is the real program name. The forbidden-character check runs before that, because pytest; rm -rf /workspace would otherwise pass the allowlist on its first word.

What working looks like. run_command.invoke({"command": "python -m pytest -q"}) returns exit code 1 and the failure. "pytest -q && rm -rf ." is refused for &&. "curl https://example.com" is refused by the allowlist. "python -c \"while True: pass\"" returns after 60 seconds with [timed out after 60s] and exit code 124.

Most likely bug. Assuming the allowlist is the security boundary. It is not: python -c "..." can do almost anything Python can. The allowlist shapes behaviour; the sandbox is the boundary. Never run allowlisted commands on your host instead of the container “because they’re safe”.

Caution

The sandbox is what protects you, so do not weaken it for convenience. Keep --network none (an agent with network access and your code can leak it), never mount your home folder or Docker’s own socket into the container, never pass your API keys into it, and run the agent only on repos you would be comfortable having read by the model provider.

Stuck? Hint

If every command comes back with exit code 126 or 127, the program is missing from the image (for example pytest when you used the stock image). Rebuild with Dockerfile.sandbox. If outputs look truncated in the middle, that is clip working; raise the limit if test failures are being cut, but keep it bounded.

5. Wire the agent loop as a LangGraph graph

Why. Now connect the model to the tools with the loop you built in this module’s first two lessons: agent asks, tools runs, back to agent, until the agent stops asking. Doing it as a graph, rather than a while loop, is what makes milestones 6 to 8 small changes instead of rewrites.

# agent_graph.py
from typing import Annotated, TypedDict

from dotenv import load_dotenv
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import SystemMessage
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode, tools_condition

from tools import list_files, read_file, search, edit_file, run_command

load_dotenv()
TOOLS = [list_files, read_file, search, edit_file, run_command]
llm = ChatAnthropic(model="claude-opus-5-5", max_tokens=4000).bind_tools(TOOLS)

SYSTEM = """You are a careful coding agent working in a Python repository.
Work like a good engineer:
1. Explore with list_files and search. Read the relevant code with read_file.
2. Run the tests with run_command to see the failure for yourself.
3. Make the smallest correct change with edit_file. Line numbers shown by
   read_file are NOT part of the file; never include them in old_string.
4. Re-run the tests. Do not edit tests to make them pass unless the task says so.
When you are done, stop calling tools and summarise what you changed and why."""

class CoderState(TypedDict):
    messages: Annotated[list, add_messages]

def agent(state: CoderState) -> dict:
    return {"messages": [llm.invoke([SystemMessage(SYSTEM), *state["messages"]])]}

builder = StateGraph(CoderState)
builder.add_node("agent", agent)
builder.add_node("tools", ToolNode(TOOLS))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", tools_condition)
builder.add_edge("tools", "agent")
graph = builder.compile()

And a CLI that streams each step so you can watch:

# main.py
import sys
from agent_graph import graph

def show(node: str, change: dict) -> None:
    for m in change.get("messages", []):
        if getattr(m, "tool_calls", None):
            for call in m.tool_calls:
                print(f"agent  -> {call['name']}({call['args']})")
        elif node == "tools":
            print(f"tools  <- {str(m.content).splitlines()[0][:100]}")
        elif m.content:
            print(f"{node:<6} : {m.content}")

task = sys.argv[1]
for update in graph.stream({"messages": [("user", task)]},
                           config={"recursion_limit": 60}, stream_mode="updates"):
    for node, change in update.items():
        if change:
            show(node, change)

What working looks like. Run uv run main.py "Make the failing test in tests/test_order_pricing.py pass without changing the test.":

agent  -> list_files({'directory': '.'})
tools  <- pricing/__init__.py
agent  -> run_command({'command': 'python -m pytest -q'})
tools  <- exit code 1
agent  -> read_file({'path': 'pricing/order_pricing.py'})
tools  <-     1  BULK_THRESHOLD = 10      # items
agent  -> edit_file({'path': 'pricing/order_pricing.py', 'old_string': '    if quantity > BULK_THRESHOLD:', 'new_string': '    if quantity >= BULK_THRESHOLD:'})
tools  <- Edited pricing/order_pricing.py.
agent  -> run_command({'command': 'python -m pytest -q'})
tools  <- exit code 0
agent  : The bulk discount used `>` so exactly 10 items got no discount. I changed it to `>=`...

Reset the practice repo with git checkout . inside repo/ between runs, so each run starts from the bug.

Six model calls. Context grows from about 2,500 tokens (system prompt plus tool schemas) to about 5,000, so the session uses roughly 25,000 input tokens and 1,000 output tokens: about $0.12, or ₹11 at ₹88 to the dollar. Prices change; check the provider’s pricing page. Remember that number, because in a long session it grows much faster than the number of steps.

Most likely bug. A recursion_limit that is too low. Each tool round trip is two steps (agent and tools), so 25 steps is only about a dozen tool calls. Real tasks need more. Set it deliberately.

Stuck? Hint

tools_condition routes to a node called exactly "tools"; name it anything else and the edge points nowhere. If the agent edits the test instead of the code, strengthen the system prompt and the task wording, and look at milestone 6: verification will catch a “fix” that breaks the other test, but not one that weakens a test, so review diffs yourself.

6. Make passing tests the stop condition

Why. Right now the run ends when the model says it is done. Models are sometimes optimistic: they make an edit, skip re-running the tests, and declare victory. The fix is to stop trusting the claim and check it. When the agent stops calling tools, a verify node runs the test suite itself. Pass, and the run ends. Fail, and the failures go back to the agent as a new message. This is the evaluator-optimizer pattern from Module 13, with the evaluator being the most honest one available: the tests.

Extend the state and add the node:

# agent_graph.py (changes)
from langchain_core.messages import HumanMessage
from sandbox import sandbox_exec

TEST_CMD = "python -m pytest -q"
MAX_ATTEMPTS = 3

class CoderState(TypedDict):
    messages: Annotated[list, add_messages]
    attempts: int
    passed: bool

def verify(state: CoderState) -> dict:
    code, output = sandbox_exec(TEST_CMD, timeout=120)
    if code == 0:
        return {"passed": True}
    return {"passed": False, "attempts": state["attempts"] + 1,
            "messages": [HumanMessage(
                f"I ran `{TEST_CMD}` and it still fails (exit code {code}). "
                f"Keep going.\n\n{output}")]}

def after_verify(state: CoderState) -> str:
    if state["passed"]:
        return "done"
    if state["attempts"] >= MAX_ATTEMPTS:
        return "give_up"
    return "retry"

builder = StateGraph(CoderState)
builder.add_node("agent", agent)
builder.add_node("tools", ToolNode(TOOLS))
builder.add_node("verify", verify)
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", tools_condition, {"tools": "tools", END: "verify"})
builder.add_edge("tools", "agent")
builder.add_conditional_edges("verify", after_verify,
                              {"done": END, "give_up": END, "retry": "agent"})
graph = builder.compile()

The neat line is the mapping on tools_condition. It still returns "tools" or END, but {END: "verify"} redirects “the agent is finished” to the verify node instead of the end of the run. In main.py, start with {"messages": [...], "attempts": 0, "passed": False} and print passed at the end.

What working looks like. Break the agent on purpose: tell it “Change BULK_DISCOUNT to 0.15 and stop”. It edits, stops, and verify reports the failing tests, so the loop goes back to the agent, which now has to reconcile the instruction with the tests. With the normal task, verify passes on the first try and the log ends with a pass.

Most likely bug. Forgetting to initialise attempts in the input, so state["attempts"] + 1 raises KeyError the first time tests fail. Or forgetting the give_up branch, which turns a hard task into an endless, expensive loop.

Stuck? Hint

Make TEST_CMD configurable (an environment variable or a CLI flag) so you can scope it to one test file on large repos, like python -m pytest -q tests/test_order_pricing.py. Full suites that take minutes make every verify expensive. When the agent gives up, print the last failure and the git diff so a human can take over from a clear starting point.

7. Gate risky actions with human approval

Why. Some actions should never happen without a person saying yes: deleting files, committing, anything that is hard to undo or leaves the sandbox. The agent should still be able to propose them, because often they are the right step. This is human-in-the-loop, and in LangGraph it is interrupt, called inside the tool itself, so the check cannot be skipped by a clever prompt.

# tools.py (continued)
from langgraph.types import interrupt

@tool
def delete_file(path: str) -> str:
    """Delete a file from the repo. Requires human approval."""
    try:
        p = safe_path(path)
    except ValueError as error:
        return f"Error: {error}"
    if not p.is_file():
        return f"Error: '{path}' does not exist."
    answer = interrupt({"action": f"delete {path}"})
    if answer != "yes":
        return f"The human declined deleting {path}. Find another way."
    p.unlink()
    return f"Deleted {path}."

@tool
def commit_changes(message: str) -> str:
    """Commit all current changes in the repo with a message. Requires human approval."""
    diff = subprocess.run(["git", "-C", str(REPO), "diff", "--stat"],
                          capture_output=True, text=True).stdout
    answer = interrupt({"action": f"commit '{message}'", "diff": diff})
    if answer != "yes":
        return "The human declined the commit."
    proc = subprocess.run(["git", "-C", str(REPO), "commit", "-am", message],
                          capture_output=True, text=True)
    return clip(proc.stdout + proc.stderr)

commit_changes runs git on your machine, outside the sandbox. That is exactly why it is gated.

Add both tools to TOOLS. interrupt needs a checkpointer, so compile with one, give the run a thread ID, and teach the CLI to resume:

# main.py (changes)
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
from agent_graph import builder

graph = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "pricing-fix-1"}, "recursion_limit": 80}

def run(inputs) -> dict | None:
    """Stream the graph; return the interrupt payload if it paused."""
    for update in graph.stream(inputs, config, stream_mode="updates"):
        if "__interrupt__" in update:
            return update["__interrupt__"][0].value
        for node, change in update.items():
            if change:
                show(node, change)
    return None

pending = run({"messages": [("user", sys.argv[1])], "attempts": 0, "passed": False})
while pending:
    print(f"\nAPPROVAL NEEDED: {pending['action']}\n{pending.get('diff', '')}")
    answer = input("Approve? [yes/no] ").strip().lower()
    pending = run(Command(resume=answer))

What working looks like. Add “then commit the fix” to the task. The agent fixes the bug, tests pass inside its loop, it calls commit_changes, and your terminal stops at APPROVAL NEEDED: commit 'Apply bulk discount at exactly 10 items' with the diff stat. Type no and the agent is told it was declined. Type yes and git log in repo/ shows the commit.

Most likely bug. Code above interrupt running twice. When you resume, LangGraph re-runs the interrupted node, and ToolNode re-runs every tool call in that model turn. If the model asked for edit_file and commit_changes in the same turn, the edit runs again on resume and fails with “old_string was not found”. Keep work before interrupt side-effect free, and tell the agent in the system prompt to call risky tools on their own, in a separate turn.

Stuck? Hint

If interrupt raises an error about a missing checkpointer, you compiled without one, or invoked without a thread_id. To make approvals survive closing the terminal, swap InMemorySaver for the MongoDBSaver from the checkpointing lesson; then a separate approve.py can resume the thread later with Command(resume="yes"). LangChain’s Anthropic integration can also turn off parallel tool calls when binding tools; check the docs for the current option name if you want to enforce “one tool per turn”.

8. Manage context for long sessions

Why. On the pricing bug, the whole session fits comfortably in the model’s context window (the maximum text it can see at once). Real tasks do not. A refactor across fifteen files can run for a hundred tool calls, and every call re-sends the entire conversation. Two problems grow together: cost, because each call pays for all previous messages again, and quality, because important early facts get buried under thousands of lines of old test output. Eventually the conversation simply does not fit.

ClipThe context window is working memory1/6
Context window (room for 4 messages)
system You are Crumb support
user Hi, I ordered a cake
assistant Happy to help!

Everything still fits, so the model can see all of it.

Each request sends the whole conversation. It must fit in the window.

You have already done half the work: clipped command output, windowed read_file, capped list_files and search. The other half is three additions.

A progress-notes file. The agent writes its own working notes (goal, findings, changes, next steps) to a file outside the repo, and the notes are put into the system prompt on every call. Notes survive compaction, because they are not part of the conversation.

# tools.py (continued)
NOTES = Path("notes/progress.md")

@tool
def update_notes(text: str) -> str:
    """Overwrite your progress notes: goal, what you've learned, what you've
    changed, what's next. Notes are shown to you on every turn and survive
    when old conversation is summarised. Update them after each milestone."""
    NOTES.parent.mkdir(exist_ok=True)
    NOTES.write_text(text)
    return "Notes saved."

A summary in state, and an agent that reads it.

# agent_graph.py (changes)
from langchain_core.messages import RemoveMessage, ToolMessage
from langchain_core.output_parsers import StrOutputParser
from tools import NOTES

class CoderState(TypedDict):
    messages: Annotated[list, add_messages]
    attempts: int
    passed: bool
    summary: str

def agent(state: CoderState) -> dict:
    system = SYSTEM
    if state.get("summary"):
        system += f"\n\nSummary of earlier work in this session:\n{state['summary']}"
    if NOTES.exists():
        system += f"\n\nYour progress notes:\n{NOTES.read_text()}"
    return {"messages": [llm.invoke([SystemMessage(system), *state["messages"]])]}

A compaction node that runs before every agent call:

summarizer = ChatAnthropic(model="claude-opus-5-5", max_tokens=2000) | StrOutputParser()
MAX_CHARS = 200_000     # roughly 50,000 tokens at about 4 characters per token
KEEP_RECENT = 12        # always keep the latest messages verbatim

def size(messages) -> int:
    return sum(len(str(m.content)) + len(str(getattr(m, "tool_calls", ""))) for m in messages)

def compact(state: CoderState) -> dict:
    msgs = state["messages"]
    if size(msgs) < MAX_CHARS or len(msgs) <= KEEP_RECENT + 1:
        return {}
    cut = len(msgs) - KEEP_RECENT
    # Never start the kept part with a tool result whose request was removed.
    while cut > 1 and isinstance(msgs[cut], ToolMessage):
        cut -= 1
    old = msgs[1:cut]                       # msgs[0] is the task: always keep it
    transcript = "\n".join(f"{type(m).__name__}: {str(m.content)[:1500]}" for m in old)
    summary = summarizer.invoke(
        "Summarise this coding session for the agent that will continue it. Keep "
        "file names, what each relevant file does, every edit made, the latest test "
        "results and open hypotheses. Be concise.\n\n"
        f"Earlier summary:\n{state.get('summary', '')}\n\nTranscript:\n{transcript}")
    return {"summary": summary, "messages": [RemoveMessage(id=m.id) for m in old]}

RemoveMessage(id=...) is understood by the add_messages reducer: instead of appending, it deletes the message with that ID. The while loop is the important detail. Each ToolMessage must follow the assistant message that requested it; if the cut lands on a tool result, the API would see a result for a request that no longer exists and reject the call. Walking the cut backwards keeps each request and its results together.

Rewire so every path into agent goes through compact first:

builder.add_node("compact", compact)
builder.add_edge(START, "compact")
builder.add_edge("compact", "agent")
builder.add_edge("tools", "compact")                       # was tools -> agent
builder.add_conditional_edges("verify", after_verify,
                              {"done": END, "give_up": END, "retry": "compact"})

Add update_notes to TOOLS, "summary": "" to the starting state, and a line to the system prompt: “Update your progress notes with update_notes after each milestone.”

Finally, resuming. Because every step is checkpointed under the thread ID, a crashed or interrupted session can continue: call run(None) with the same config, which resumes from the last checkpoint. With MongoDBSaver this works across restarts, and you can inspect any session afterwards with graph.get_state_history(config), as in the debugging lesson.

What working looks like. To test without a huge task, temporarily set MAX_CHARS = 8_000 and KEEP_RECENT = 6, and run the pricing task. Stream with stream_mode="updates" and you will see a compact update that removes several messages and writes a summary. The agent carries on without re-reading files it already summarised, and the notes file describes the bug and the fix.

Most likely bug. Removing the first message, the task, so the agent forgets what it was asked to do, or the conversation starts with an assistant turn. Always keep msgs[0]. The second most likely: a summary that drops the one detail that mattered (an exact error message). That is what the notes file is for; prompt the agent to record key facts there.

Stuck? Hint

If you get an API error about a tool_result without a matching tool_use after compaction, your cut split a pair; print the types of msgs[cut-1:cut+2] to see where. Count characters rather than tokens to start; it is crude but free. For an accurate figure, the Anthropic API has a token-counting endpoint (see the docs). Summaries are themselves model calls, so log how often compaction runs; if it runs every few steps, raise the threshold or clip tool outputs harder.

Done means done

  • The sandbox runs with --network none The sandbox runs with `--network none`, memory and CPU limits, and only the repo mounted.
  • No tool can read, write or delete a path outside the repo (test with ../ No tool can read, write or delete a path outside the repo (test with `../`).
  • edit_file `edit_file` refuses zero or multiple matches with an instructive error.
  • run_command `run_command` refuses non-allowlisted programs and shell operators, and long commands time out.
  • On the practice repo, the agent fixes the failing test without editing the test, and verify On the practice repo, the agent fixes the failing test without editing the test, and `verify` confirms it.
  • A task that cannot be solved ends after three verify attempts with a clear message, not a recursion error.
  • delete_file and commit_changes `delete_file` and `commit_changes` pause for approval, and "no" is respected.
  • With a low compaction threshold, the session compacts and still finishes correctly.
  • A session can be resumed from its thread ID.
  • No API key appears in code, logs, the sandbox or the repo.

Stretch goals

  • Persistent sessions. Switch to MongoDBSaver and add uv run main.py --resume pricing-fix-1.
  • A cost meter. Read token usage from each model response’s metadata and print a running total in rupees.
  • Diff review before finishing. Add a node after a passing verify that shows git diff and asks for approval before the run ends.
  • Subagent for exploration. Give the main agent a research(question) tool that runs a separate read-only graph with its own context and returns only a short answer (the subagent idea from Module 13), so exploration does not fill the main context.
  • GitHub integration. Open a pull request with the fix, gated behind approval.
  • An eval set. Ten small broken repos with known fixes; measure how many the agent solves and at what cost (Module 23).

Common pitfalls

  • Running commands on the host “just for now”. Every shortcut around the sandbox becomes permanent. Always go through sandbox_exec.
  • Unbounded tool output. One cat of a log file can add 50,000 tokens to every later call. Clip everything.
  • Trusting “I’m done”. Verification is the stop condition, not the model’s summary.
  • Letting the agent weaken tests. Say so in the prompt, and review diffs to test files with extra suspicion.
  • No attempt cap. A stubborn bug plus no cap equals a large bill.
  • Side effects before interrupt. The node, and every tool call in that turn, re-runs on resume.
  • Treating repo content as trusted. A README or comment can contain instructions aimed at the model. The sandbox, the allowlist and approvals are what keep that from becoming damage.

Show it off

  • README. One paragraph on what it does, the architecture diagram, the safety model (sandbox flags, allowlist, approvals, path checks), and a “how to run” section that starts from docker build.
  • Demo video. Ninety seconds: show the failing test, start the agent, let the streamed log scroll, approve the commit, show git log and the passing tests. Then show it refusing rm -rf and timing out an infinite loop; the refusals are the impressive part.
  • LinkedIn post. Lead with the problem and the numbers: “A test had been failing for two weeks. My coding agent fixed it in six model calls for about ₹11, inside a Docker sandbox with no network.” Then explain one design decision you are proud of, like exact-match edits or tests as the stop condition, and link the repo.

Arjun ran it on the real Crumb repo on Thursday afternoon. The agent read the pricing module, ran the tests, changed one character, ran them again, and asked permission to commit. Priya reviewed the diff: one line, > to >=. She approved it.

“Two weeks,” said Meera, looking at the green tick on GitHub. “And it took that thing two minutes?”

“Two minutes and about eleven rupees,” said Arjun. “But the part I actually built was everything that stops it doing something stupid. The fixing was the easy bit.”

Priya smiled. “Now you know how the coding tools you use every day work. And why they ask before they delete anything.”

Your build checklist

0/8 done

Check your understanding

3 quick questions
1. Why does edit_file refuse when old_string appears more than once in the file?
2. The command allowlist includes python. What actually protects your machine if the model runs `python -c "..."` with harmful code?
3. When compacting old messages, why must the cut never leave a tool result without the assistant message that requested it?