Solutions — Three LangGraph Exercises
Lesson 05 · Building Your Own Agents · worked answers with commentary
Below are reference solutions for the three homework tasks. Read them after you’ve taken your own swing — each section also calls out the most common mistake we see, so comparing your draft to the “why this not that” commentary is where the learning actually happens.
How to use this page
The code blocks are minimal — they show only the lines that change relative to the
lab’s hello_graph.py. Drop them into your file, re-run, and compare the
printed trace to the “expected behaviour” notes. If something doesn’t match,
the “common mistake” box is the first place to look.
Solution to Task 1 — Reducer on a list field
The refactor is small but load-bearing — the state declaration changes, and the node bodies shrink:
from typing import Annotated, TypedDict
import operator
class S(TypedDict):
n: int
log: Annotated[list[str], operator.add]
def add_one(s: S) -> dict:
return {"n": s["n"] + 1, "log": ["add_one"]}
def double(s: S) -> dict:
return {"n": s["n"] * 2, "log": ["double"]}
Why this is cleaner. Each node now declares only what it contributed
— it doesn’t need to know or care about the previous state of log. The
reducer (operator.add on a list, i.e., list concatenation) merges new entries onto
whatever was already there. With the default last-write-wins reducer you have to re-emit the
full history every time, which is verbose and wrong the moment two nodes update
log concurrently — one fan-out branch would silently overwrite the other’s
entries because each had a stale view of the list when it computed its return value.
Expected behaviour. Re-run with {"n": 3, "log": []} and the
printed log is identical to the baseline: 8× "add_one"
followed by 1× "double" — 9 entries in the same order as before.
The behaviour is the same; the code is shorter and the merge semantics are now declared
in the type, not duplicated across every node body.
Common mistake — annotating a scalar with operator.add
It’s tempting to also write n: Annotated[int, operator.add] “to be
consistent.” Don’t. With that annotation, operator.add becomes integer
addition, so every node’s return value is summed into n instead of
replacing it — add_one returning {"n": 4} would
update n from 3 to 7 (3 + 4), not 4.
Reducers belong on accumulating fields (lists, sets, dicts that you want merged across
parallel branches). Scalars should keep the default replace semantics.
Solution to Task 2 — Three-way branch with a halve node
Add the new node, the route function, and register the three-way conditional edge. The diff versus the lab’s Step-4 graph:
def halve(s: S) -> dict:
return {"n": s["n"] // 2, "log": ["halve"]}
g.add_node("halve", halve)
def route(s: S) -> str:
if s["n"] > 20:
return "double"
if s["n"] > 10:
return "halve"
return "add_one"
g.add_conditional_edges(
"add_one",
route,
{"add_one": "add_one", "halve": "halve", "double": "double"},
)
g.add_edge("halve", END)
g.add_edge("double", END)
Expected trace from {"n": 3, "log": []}. add_one loops
eight times (3→4→5→6→7→8→9→10→11). At n == 11
the router returns "halve" (because 11 > 10 but not
> 20). halve runs once, giving n == 5, then the graph
routes to END. Final state: n = 5, log has 9 entries
ending in "halve". Inspecting the graph with
print(app.get_graph().draw_mermaid()) now shows three outgoing edges from
add_one instead of two.
Common mistake — condition order in the router
Writing the router as
if n > 10: return "halve" before
if n > 20: return "double" is the classic bug. For n == 21 the
first if fires (since 21 > 10 is true), so double is
never reached — the graph silently halves when it should have doubled. Always order
conditions from most-restrictive to least-restrictive, or use explicit
bounds (10 < n <= 20) so the intent is in the code, not in the ordering.
A second classic miss: forgetting to add all three keys to the conditional-edge mapping
dict. If route can return "halve" but the mapping only lists
"add_one" and "double", LangGraph raises a routing error (or, in older
versions, silently loops back to add_one). Print the return value of
route once during a run if you’re not sure which strings it can produce.
Solution to Task 3 — interrupt_before and resume
Compile the graph with an interrupt point, then drive it through pause & resume:
app = g.compile(checkpointer=memory, interrupt_before=["double"])
config = {"configurable": {"thread_id": "interrupt-demo"}}
# First call: runs up to the boundary, then pauses.
print(app.invoke({"n": 25, "log": []}, config=config))
# Inspect what's queued.
snapshot = app.get_state(config)
print("values:", snapshot.values)
print("next:", snapshot.next)
# → next: ('double',)
# Human reviews snapshot.values, decides to proceed.
# Resume: pass `None` as input, same thread_id.
print(app.invoke(None, config=config))
Why this matters. Expensive or irreversible actions — paying an invoice,
sending an email, kicking off a deployment, calling an LLM that costs real money — belong
behind a human-in-the-loop gate. interrupt_before=["double"] is a one-line config
that gives you that gate: no custom signalling, no extra threads, no shared queues. The
checkpointer holds the full state durably under the thread ID, and any separate process
(a UI, a Slack approval bot, a human running a CLI tomorrow morning) can resume the run by
calling app.invoke(None, config=...) once approval lands. The graph itself
doesn’t care whether the gap was 10 milliseconds or 10 hours — the checkpoint is
the contract.
Expected behaviour. The first invoke returns immediately after the
add_one loop (or after whichever step precedes double in your graph)
without ever printing anything double would log. snapshot.next equals
("double",). The second invoke(None, ...) completes the run and the
final state has "double" appended to log.
Common mistake — resuming with {} instead of None
On resume you must pass None as the input, not {}. The empty dict is
a real input value that the default replace-reducer will use to overwrite parts of
your state — you can silently lose n, log, or both. None
means “no new input, just continue from the checkpoint.” The asymmetry is awkward
but load-bearing — remember it as “invoke with data to start, invoke with
None to resume.”
Closing tip. You now have LangGraph in your fingertips: reducers for clean state merges, conditional edges for branching control flow, and interrupts for human-in-the-loop gates. Lesson 06 puts all three to work: a graph-shaped pipeline agent with an LLM in every node, a critique-and-revise loop, and checkpoint resume in a real research-write task. See you there.