This post is part of the Agentic Developer: Zero to Advanced series. It follows on from Part 5 – what makes it an agent, and it is the second post of Arc 2, the agent loop.
Rewritten on 2026-10-18 on the synthetic void dataset, with fresh runs. The first version used GDXray images that I may not redistribute. The conclusion (the graph behaves like the loop) did not change, the numbers did. The narrated animations below were rebuilt with the new numbers.
Part 5 ended with a hand-written for loop that was more precise than the plain detector and found fewer real voids. This post does not change that. It does something more boring and more useful first: it rebuilds the same agent as a LangGraph graph, runs it on the same 400 images, and checks that nothing changed.
Nothing should change. Same model, same tools, same prompt, same scoring. The only difference is how the control flow is written down. A for loop with three ifs becomes three nodes and a few edges. Once the flow is a graph, you can draw it, pause it before any step, and test every path. That is what Parts 7 to 9 build on.
If you would rather watch than read, each section below has a short narrated animation.
0.1 From a loop to a graph
Here is what Part 5’s loop was doing, in plain words:
- Ask the model what to do.
- If it asked for a tool, run the tool and put the result in the messages list. Ask again.
- If it just talked, remind it to use a tool. Ask again.
- If it submitted a report, or we hit 12 steps, stop.
Steps 2, 3 and 4 are decisions about what happens next. In the loop they were hidden inside if statements. In a graph they become arrows you can see.

Daily-life version: a kitchen with stations. The nodes are the stations (chopping, frying, plating). The edges are the signs that say “if the order has fish, go to the grill, otherwise go to the pan”. The state is the order ticket that travels with the dish. The kitchen does not change when you draw it on a wall. You just stop having to guess where a dish is.
0.3 Nodes: plain Python functions
A node is a function that takes the state and returns only the keys it wants to change. Here is the one that asks the model, the same call as the first line inside Part 5’s loop:
notebooks/agentic-zero-to-advanced/graph_agent.py
def agent_node(state: AgentState) -> dict:
"""Ask the model what to do next. Same call as the first line inside Part 5's loop."""
resp = client.chat.completions.create(
model=MODEL, messages=state["messages"], tools=TOOL_SPECS + [SUBMIT_SPEC], temperature=0.2)
msg = resp.choices[0].message
return {"messages": [msg.model_dump(exclude_none=True)], "step": state["step"] + 1,
"tokens": state["tokens"] + resp.usage.total_tokens}It returns three keys. messages is a list with one item, which the reducer appends. step and tokens are new numbers, which replace the old ones. trace and report are not mentioned, so they stay as they were.
Show the other two nodes (tools and nudge)
notebooks/agentic-zero-to-advanced/graph_agent.py
def tools_node(state: AgentState) -> dict:
"""Run every tool the model asked for and put the results back as messages."""
last, results, calls, report = state["messages"][-1], [], [], None
for call in last["tool_calls"]:
args = json.loads(call["function"]["arguments"] or "{}")
name = call["function"]["name"]
if name == "submit_report":
report, result = args, {"ok": True}
elif name in TOOL_FUNCS:
try:
result = TOOL_FUNCS[name](**args)
except Exception as exc: # tool errors go back to the model, they don't crash the graph
result = {"error": str(exc)}
else:
result = {"error": f"unknown tool {name}"}
calls.append({"tool": name, "args": args, "result": result})
results.append({"role": "tool", "tool_call_id": call["id"], "content": json.dumps(result)})
return {"messages": results, "report": report,
"trace": [{"step": state["step"], "says": last.get("content"), "calls": calls}]}
def nudge_node(state: AgentState) -> dict:
"""The model talked instead of acting. Remind it, once per step, same wording as Part 5."""
return {"messages": [{"role": "user", "content": "Use a tool, or call submit_report if you are done."}],
"trace": [{"step": state["step"], "says": state["messages"][-1].get("content"), "calls": []}]}0.4 Edges: who goes next
Edges are where the if statements went. A conditional edge is a small function that looks at the state and returns the name of the next node.
notebooks/agentic-zero-to-advanced/graph_agent.py
def after_agent(state: AgentState) -> str:
"""Edge 1: did the model ask for a tool? Yes -> tools. No -> nudge (or stop at the step limit)."""
if state["messages"][-1].get("tool_calls"):
return "tools"
return "nudge" if state["step"] < MAX_STEPS else END
def after_tools(state: AgentState) -> str:
"""Edge 2: submitted the report, or out of steps? Stop. Otherwise go round again."""
return END if state["report"] or state["step"] >= MAX_STEPS else "agent"
def build_graph(**compile_kwargs):
g = StateGraph(AgentState)
g.add_node("agent", agent_node)
g.add_node("tools", tools_node)
g.add_node("nudge", nudge_node)
g.add_edge(START, "agent")
g.add_conditional_edges("agent", after_agent, ["tools", "nudge", END])
g.add_conditional_edges("tools", after_tools, ["agent", END])
g.add_conditional_edges("nudge", lambda s: END if s["step"] >= MAX_STEPS else "agent", ["agent", END])
return g.compile(**compile_kwargs)That is the whole agent. The for loop is gone. The graph keeps going until an edge returns END.
0.4.1 Play with it
Be the model. Pick what it does at each step and watch the state and the path change. Try submitting a report straight away, or keep asking for tools until the step limit stops you.
0.5 Run it
First, the graph as data. LangGraph can list its own nodes and edges, which is a nice sanity check that the picture you drew matches the code you wrote. Then one real image from the held-out split.
notebooks/agentic-zero-to-advanced/graph_inspect_demo.py
from graph_agent import GRAPH, run_graph_agent
g = GRAPH.get_graph()
print("nodes:", [n for n in g.nodes])
for e in g.edges:
print(f" {e.source:>9} -> {e.target:<9} {'(conditional)' if e.conditional else ''}")
r = run_graph_agent("test_00020")
print({k: v for k, v in r.items() if k != "trace"})
for s in r["trace"]:
calls = ", ".join(f"{c['tool']}({', '.join(f'{k}={v}' for k, v in c['args'].items())})" for c in s["calls"])
print(f"step {s['step']}: {calls or '(just talked)'}")Show output
nodes: ['__start__', 'agent', 'tools', 'nudge', '__end__']
__start__ -> agent
agent -> __end__ (conditional)
agent -> nudge (conditional)
agent -> tools (conditional)
nudge -> __end__ (conditional)
nudge -> agent (conditional)
tools -> __end__ (conditional)
tools -> agent (conditional)
{'image': 'test_00020', 'split': 'test', 'finished': True, 'steps': 6, 'tool_calls': 6, 'tokens': 6806, 'seconds': 15.8, 'n_candidates': 4, 'kept': 2, 'verdict': 'review', 'tp': 1, 'fp': 1, 'fn': 0}
step 1: find_candidates(image=test_00020)
step 2: measure_candidate(image=test_00020, candidate_id=0)
step 3: measure_candidate(image=test_00020, candidate_id=1)
step 4: measure_candidate(image=test_00020, candidate_id=2)
step 5: measure_candidate(image=test_00020, candidate_id=3)
step 6: submit_report(kept_ids=[0, 1], verdict=review, reason=Candidates 0 and 1 show high contrast and reasonable areas, suggesting they are likely real defects. Candidates 2 and 3 have low contrast and are near the image edge, making them less reliable.)Eight edges, as drawn. The agent measured each of the four boxes one at a time, then submitted, the same pattern as Part 5 (this time it measured all four, where the Part 5 trace skipped one). On this image it kept two boxes: one real void, one false alarm (tp=1, fp=1). One image tells you nothing about quality, so let’s score all of them.
0.6 Pause it, look inside, resume
Here is the first thing a graph gives you that a for loop does not. If you compile it with a checkpointer, LangGraph saves the state after every node. Then interrupt_before=["tools"] stops the run right before a tool executes, and you can read what the model is about to do.
notebooks/agentic-zero-to-advanced/graph_pause_demo.py
graph = build_graph(checkpointer=MemorySaver(), interrupt_before=["tools"])
cfg = {"configurable": {"thread_id": "demo"}}
graph.invoke(initial_state("test_00020"), cfg)
snap = graph.get_state(cfg)
print("paused before node:", snap.next)
want = snap.values["messages"][-1]["tool_calls"][0]["function"]
print("model wants to call:", want["name"], want["arguments"])
print("messages so far:", len(snap.values["messages"]), "| step:", snap.values["step"])
graph.invoke(None, cfg) # resume: None means "continue from the saved state"
snap = graph.get_state(cfg)
print("paused again before:", snap.next, "| step:", snap.values["step"], "| tool results so far:",
sum(m["role"] == "tool" for m in snap.values["messages"]))Show output
It stops, we read the pending call, and invoke(None, cfg) continues from the saved state. It ran the tool, asked the model again, and paused once more before the next tool. This is the hook for Part 9, where “pause before the risky step and wait for a human” becomes a real feature.
Daily-life version: a video game with a save point before every door. You can walk up to the door, read the sign, and decide whether to go in. A for loop is a game with no saves: you find out what was behind the door by walking through it.
0.7 Test every edge
There is a catch. In the real 400-image run below, the nudge node never ran. Qwen3-Coder always called a tool. So the real run proves nothing about that edge, or about the step limit.
For that I use a scripted fake model. Each call returns the next prepared message, so I can force every path on purpose, with no GPU and no vLLM.
notebooks/agentic-zero-to-advanced/test_graph_paths.py
img = "test_00020"
# 1. happy path: find, submit -> END via the tools node
final, path = run([msg(calls=[("find_candidates", {"image": img})]),
msg(calls=[("submit_report", {"kept_ids": [], "verdict": "pass", "reason": "nothing"})])])
# 2. the model talks once, gets nudged, then submits
final, path = run([msg("I think it is fine."),
msg(calls=[("submit_report", {"kept_ids": [], "verdict": "pass", "reason": "ok"})])])
# 3. the model never submits: the step limit must stop the graph
final, path = run([msg(calls=[("find_candidates", {"image": img})])] * ga.MAX_STEPS)
# 4. the model only ever talks: the nudge edge must also respect the step limit
final, path = run([msg("hmm")] * ga.MAX_STEPS)Show output
All four paths behave. A runaway model stops at step 12 whether it keeps calling tools or keeps chatting. This kind of test is hard to write for a for loop and easy for a graph, because each node is a function and each edge is a function.
0.8 Same agent, same score
The same 400 images as Part 5: the first 200 of the dev split (val) and the first 200 of the held-out split (test), Qwen3-Coder-30B on local vLLM, scored the way Part 5 scored (a kept box is a hit at IoU 0.2 or more against the exact void boxes). Only the Part 5 loop’s numbers are copied from last time. The graph’s numbers are from a fresh run.
notebooks/agentic-zero-to-advanced/graph_agent.py
Show output (the held-out summary, not the per-image lines)
"test": {
"graph": {
"n_images": 200, "finished": 200, "avg_steps": 3.82, "avg_tool_calls": 3.89,
"avg_tokens": 3887, "avg_seconds": 27.1,
"tp": 64, "fp": 218, "fn": 323, "precision": 0.227, "recall": 0.165
},
"part5_loop": {
"n_images": 200, "finished": 200, "avg_steps": 3.8, "avg_tool_calls": 3.85,
"avg_tokens": 3862, "avg_seconds": 27.0,
"tp": 63, "fp": 215, "fn": 324, "precision": 0.227, "recall": 0.163
}
}| Held-out (200 images, 387 voids) | Real voids found (TP) | False alarms (FP) | Missed (FN) | Precision | Recall | Steps per image | Tokens per image |
|---|---|---|---|---|---|---|---|
| Part 5 loop | 63 | 215 | 324 | 22.7% | 16.3% | 3.80 | 3,862 |
| Part 6 graph | 64 | 218 | 323 | 22.7% | 16.5% | 3.82 | 3,887 |
| Dev (200 images, 420 voids) | TP | FP | FN | Precision | Recall |
|---|---|---|---|---|---|
| Part 5 loop | 98 | 185 | 322 | 34.6% | 23.3% |
| Part 6 graph | 100 | 185 | 320 | 35.1% | 23.8% |
The two agree to within noise: one real void apart on either split, the same precision to the decimal on held-out, the same steps and tokens. All 400 runs finished with submit_report; none hit the step limit.
Three things I checked, because “same numbers” is easy to claim and easy to get wrong:
- Image by image, they are not identical. The kept-box counts match on 181 of 200 held-out images (176 of 200 on dev). The rest differ by a box or two, because the temperature is 0.2, not
- The totals agree; the individual runs do not.
- The wall-clock time is not comparable. Both took about 27 s per image here, but I did not control for what else the machine was doing during either run, and nothing in the graph version does less work: steps and tokens per image are the same. I would not read anything into the timing either way.
- The
nudgenode never ran, on any of the 400 runs. Qwen3-Coder always called a tool. That is why the scripted test above exists.
The agent is still more precise and less sensitive than the plain detector, exactly as in Part 5, and Part 5’s fixes still apply. Part 6 is about the container, not the contents.
0.9 Jargon, explained simply
Graph. A set of steps (nodes) and arrows between them (edges). Here: three nodes and eight edges, including START and END.
Daily-life version: a subway map. Stations are nodes, lines are edges. You do not care how the tunnel was dug, only where a train can go next.
State. The data every node can read and write. The graph’s memory.
Daily-life version: the order ticket on a restaurant rail. Every station reads it and adds its line. Nobody keeps a private copy.
Node. One step: a plain function that takes the state and returns what it changed.
Daily-life version: one station in the kitchen. It takes the ticket, does its job, and writes “done” on the ticket.
Edge. The rule for which node runs next. A conditional edge looks at the state to decide.
Daily-life version: a road sign at a fork. “Trucks left, cars right.” The sign reads the vehicle, it does not drive.
Reducer. The rule for merging a node’s answer into the state: append, replace, add up.
Daily-life version: a group chat versus a sticky note. A chat appends every message. A sticky note is replaced when someone writes a new one.
Checkpointer. Saves the state after every node, so you can pause, inspect and resume.
Daily-life version: an autosave in a video game. The save point is how you can go back, and also how you can stop to think.
Interrupt. A rule that pauses the graph before or after a chosen node.
Daily-life version: a security gate at an airport. The flight is fine, but someone has to look at your passport before you go through.
Step limit. A hard cap on how many times the agent node may run, so a confused model cannot loop forever.
Daily-life version: a kitchen timer. Not for the dish, for you.
0.10 What I did not do
- One run per setup. Both the loop and the graph ran once on 400 images. With temperature 0.2 the totals will move a little between runs, and I did not repeat either to measure how much.
- No faster, no cheaper, no better claim. Same steps, same tokens, same detections. If anything in this post sounds like “the graph is better at the job”, I wrote it badly.
- One dataset. All images come from the one synthetic void dataset, as in Part 5, and only the first 200 of each split.
- The narrated videos were checked by transcript and by looking at frames, not by ear. The transcript matches the script. Whether every word sounds right, I have not listened for.
0.11 Reproduce this yourself
Show reproduce commands
cd quarto_blog_hasan/notebooks/agentic-zero-to-advanced
uv sync # langgraph is already in pyproject.toml
# build the dataset first (Part 2); local vLLM on :8000 serving qwen3-coder, as in Parts 3-5. .env as in Part 4.
uv run python graph_inspect_demo.py # nodes, edges, one real image
uv run python graph_pause_demo.py # pause before tools, inspect, resume
uv run python test_graph_paths.py # every edge, scripted fake model, no GPU
uv run python graph_agent.py # 400 images, ~20 min, writes out/graph_agent_*.json
uv run python generate_arc2_diagrams.py # the two hand-drawn diagrams0.12 What’s next
Further reading: the LangGraph overview covers StateGraph, reducers and persistence; the persistence page is the one behind interrupt_before.
Part 7 turns this graph into the real inspection pipeline: classify the defect, check business rules, decide, report. More nodes, same three ideas.
Reuse
Citation
@online{goni2026,
author = {Goni, Hasan},
title = {Agentic {Developer:} {Zero} to {Advanced,} {Part} 6:
{Building} the {Graph}},
date = {2026-10-17},
langid = {en}
}
