Lesson 05 LangGraph Intro
Theme:

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:

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.

View solutions →