Homework — Three LangGraph Exercises
Lesson 05 · Building Your Own Agents · ~45 min
All three tasks below extend hello_graph.py from the Lab 05 walk-through —
the 3-node graph with the add_one/double nodes, the conditional edge
that loops on n ≤ 10, and the SqliteSaver checkpointer. Keep that
file open as your base; each task is a focused extension that exercises a different LangGraph
primitive (reducers, branching, interrupts).
Before you start
Make sure python hello_graph.py runs end-to-end on the lab’s base input
({"n": 3, "log": []}) and prints a final state with eight add_one
entries followed by one double. If it doesn’t, revisit the lab guide
before tackling the homework — debugging the extensions is much easier on a working
baseline.
Task 1 — Replace the default reducer with operator.add on a list field
Currently log is updated by returning the entire new list from each
node (e.g., "log": s["log"] + ["add_one"]). That works because LangGraph’s
default reducer is last-write-wins — each node has to re-emit the full history or it
loses the previous entries. That’s clunky, and it gets dangerous the moment two nodes
update log concurrently (a node could overwrite another’s entries because
it had a stale view of the list).
Refactor so log is annotated with Annotated[list[str], operator.add]
and each node returns only its new entry. The state definition becomes:
from typing import Annotated, TypedDict
import operator
class S(TypedDict):
n: int
log: Annotated[list[str], operator.add]
Update add_one and double so they return only the delta:
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"]}
Re-run the script with {"n": 3, "log": []}. The final log list
must still contain the full ordered history (["add_one", "add_one", ..., "double"]).
Then write a 2-3-sentence note in a comment at the top of the file
explaining (a) why this version is cleaner and (b) what concrete bug would have appeared with
the default last-write-wins reducer in a fan-out scenario.
✓ Self-check: the printed log contains the same 9 entries in the same order as the baseline; the node bodies are now noticeably shorter; your 2-3-sentence note explains the “why” in your own words.
Task 2 — Add a third halve node and a three-way branch
Add a new node:
def halve(s: S) -> dict:
return {"n": s["n"] // 2, "log": s["log"] + ["halve"]}
(If you’ve already done Task 1, write the body as return {"n": s["n"] // 2, "log": ["halve"]} instead — the reducer will append for you.)
Register the node and change the conditional edge after add_one from a 2-way
decision into a 3-way branch:
"double"ifn > 20"halve"ifn > 10(and<= 20)"add_one"otherwise (loop)
Both double and halve route to END. Update the
route function and the conditional-edge mapping accordingly, then re-run with
{"n": 3, "log": []}.
✓ Self-check: the trace shows add_one eight times (3→4→5→...→11), then halve (because at n == 11 we fall into the 10 < n ≤ 20 branch), then END. The final n is 5 and the log has 9 entries ending in "halve". The Mermaid diagram from app.get_graph().draw_mermaid() now shows three outgoing edges from add_one.
Don’t forget the mapping
add_conditional_edges takes a mapping dict that translates
each possible return value of your route function into a target node name.
If route can now return three strings, all three must be keys in the dict
— otherwise the graph errors out (or, worse, silently loops). Print the return value
of route once during a run if you’re unsure which strings it produces.
Task 3 — Use interrupt_before to pause the graph
Take the graph from Task 2 (or the Step-4 conditional version if you skipped Task 2) and re-compile it with an interrupt point:
app = g.compile(checkpointer=memory, interrupt_before=["double"])
Re-run with a fresh thread_id and an input that will reach double
(e.g., {"n": 25, "log": []} if you’re on the Task-2 graph, or any
n that eventually exceeds 10 on the Step-4 graph):
config = {"configurable": {"thread_id": "interrupt-demo"}}
print(app.invoke({"n": 25, "log": []}, config=config))
The graph stops before the double node runs and
.invoke(...) returns. Inspect what’s waiting next:
snapshot = app.get_state(config)
print("values:", snapshot.values)
print("next:", snapshot.next)
You should see next=("double",) — the state is checkpointed at the
boundary, and the graph knows what it would have run next. Now resume by calling
invoke with None as the input and the same config
(no new input — the checkpoint already has the state):
print(app.invoke(None, config=config))
The graph continues from double and runs to END. Write
2-3 sentences (again, as a comment in your file) on how this pattern
enables human-in-the-loop approval — e.g., a human reviews the checkpointed
state before an expensive or irreversible step (a payment, a destructive write, a costly LLM
call) runs.
✓ Self-check: the first invoke returns without ever printing anything double would have logged; app.get_state(config).next equals ("double",); the second invoke(None, ...) completes the run and the final state contains "double" in its log; your 2-3-sentence note connects the interrupt mechanism to a real human-approval workflow.