Lesson 05 LangGraph Intro
Theme:

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.