Exercises — Week 1 — Branching state¶
Do these after reading Week 1. The “done when” is two inputs taking different paths. Return partial dicts. Use a list reducer.
import operator
from typing import Annotated, Literal, TypedDict
from langgraph.graph import END, START, StateGraph
Predict before you run¶
Does "I want a refund" ever enter the docs node? After a docs ticket, is log ["classified", "docs"] or did you .append in place?
Starter / TODO¶
import operator
from typing import Annotated, Literal, TypedDict
from langgraph.graph import END, START, StateGraph
# TODO: classify → route → docs OR refund_queue
# TODO: log: Annotated[list[str], operator.add]
Runnable command¶
Each task has three hints, closed by default. Open only as far as you need.
1. Two paths¶
Build a CloudWave ticket graph: classify → route → docs or refund_queue. Keyword ifs are fine.
Checks:
"How do I rotate an API key?"ends with a docs-path label"I want a refund"ends with a refund-path label and never ran the docs node (count aRUNScounter like the week-3 lesson, or a path field)
Hint 1 — a nudge
A graph is a switch statement you can inspect. Which function returns the case label, and which nodes are the cases?
Hint 2 — the approach
classify writes a decision. add_conditional_edges("classify", route) — where route just returns state["decision"] — picks the next node by name. Bump a module-level RUNS counter inside each branch node so you can prove which ones ran.
Hint 3 — most of the code
import operator
from typing import Annotated, Literal, TypedDict
from langgraph.graph import END, START, StateGraph
RUNS = {"docs": 0, "refund_queue": 0}
class Ticket(TypedDict):
content: str
decision: str
log: Annotated[list[str], operator.add]
def classify(state: Ticket) -> dict:
decision = "refund_queue" if "refund" in state["content"].lower() else "docs"
return {"decision": decision, "log": ["classified"]}
def docs(state: Ticket) -> dict:
RUNS["docs"] += 1
return {"log": ["docs"]}
def refund_queue(state: Ticket) -> dict:
RUNS["refund_queue"] += 1
return {"log": ["refund"]}
def route(state: Ticket) -> Literal["docs", "refund_queue"]:
return state["decision"] # type: ignore[return-value]
g = StateGraph(Ticket)
for name, fn in (("classify", classify), ("docs", docs), ("refund_queue", refund_queue)):
g.add_node(name, fn)
g.add_edge(START, "classify")
g.add_conditional_edges("classify", route)
g.add_edge("docs", END)
g.add_edge("refund_queue", END)
app = g.compile()
for text in ("How do I rotate an API key?", "I want a refund"):
print(text, "→", app.invoke({"content": text, "decision": "", "log": []})["log"], RUNS)
2. Reducer¶
State has log: Annotated[list[str], operator.add]. classify returns {"log": ["classified"]}; the branch node returns {"log": ["docs"]} or {"log": ["refund"]}.
Checks:
- After a docs ticket,
logis["classified", "docs"](both entries present) - You did not
state["log"].append(...)as the only write
Hint 1 — a nudge
Two nodes both write log. Without instructions, the graph keeps the last write. What tells it to combine them instead?
Hint 2 — the approach
Annotated[list[str], operator.add] is the instruction: new values are +-ed onto the old list. Each node returns a one-item list, never the whole list and never an in-place .append. To see why it matters, build the same graph with a plain list[str] field and compare.
Hint 3 — most of the code
class NoReducer(TypedDict):
content: str
decision: str
log: list[str]
g2 = StateGraph(NoReducer)
for name, fn in (("classify", classify), ("docs", docs), ("refund_queue", refund_queue)):
g2.add_node(name, lambda state, fn=fn: fn(state)) # unannotated: LangGraph reads schemas from type hints
g2.add_edge(START, "classify")
# Unannotated too: LangGraph 1.x also reads the router's type hints, and `route` names Ticket.
g2.add_conditional_edges("classify", lambda state: route(state), ["docs", "refund_queue"])
g2.add_edge("docs", END)
g2.add_edge("refund_queue", END)
print("no reducer:", g2.compile().invoke({"content": "rotate key", "decision": "", "log": []})["log"])
3. Partial updates¶
A node that only knows the decision returns {"decision": "docs"} and does not pass the rest of the state through by mutation.
Checks:
classify(...)returns a dict whose keys are a subset of the state schema- Two invokes do not share a list object (no leaked labels from ticket 1 into ticket 2)
Hint 1 — a nudge
A node returns a diff, not the new state. What happens to the fields it doesn't mention?
Hint 2 — the approach
Call classify directly and compare its keys to Ticket.__annotations__. Then invoke the app twice and check the second result's log has no labels from the first.
Hint 3 — most of the code
diff = classify({"content": "I want a refund", "decision": "", "log": []})
print("keys returned:", set(diff), "schema:", set(Ticket.__annotations__))
first = app.invoke({"content": "I want a refund", "decision": "", "log": []})
second = app.invoke({"content": "rotate key", "decision": "", "log": []})
print(first["log"], second["log"], first["log"] is second["log"])
Expected observation¶
Open after you run
Two inputs, two paths. Refund path never increments a docs counter. Partial updates return a subset of the state keys.
Self-check¶
Return partial dicts. Use a list reducer. Two invokes do not share a list object.