Agent state machines (LangGraph-shaped)#
Track 04 - Agents · Notebook 03 · Runtime: ≈30 s on CPU
Prerequisites:
04_agents/01(ReAct from scratch).Reference: LangGraph docs - StateGraph.
What#
LangGraph models an agent as a directed graph over a shared state
dict. Nodes are pure functions (state) -> state_update; edges can
be unconditional or routed by a predicate on the state. The engine
runs a loop: execute the current node, apply the state update, route
to the next node, repeat until an END node is reached.
This structure is what distinguishes a “real” agent framework from
just a for-loop with a parser: you can declare the whole control
flow up front, visualise it, and swap out nodes without touching
the rest.
We don’t depend on langgraph. A 50-line StateGraph clone built
from dict state + edge tables reproduces the core semantics, and
makes the shape of the abstraction obvious.
from llm_systems_cookbook.nb import bootstrap
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
s = bootstrap("04_agents_03_langgraph_state_machines")
Minimal StateGraph#
Three operations:
add_node(name, fn)- register a pure state->update function.add_edge(src, dest)- unconditional transition.add_conditional_edges(src, router, mapping)-router(state)returns a key;mapping[key]is the next node.
run(state) drives the loop from "__start__" until it reaches
"__end__" or hits the step limit.
END = "__end__"
START = "__start__"
@dataclass
class StateGraph:
nodes: dict[str, Callable[[dict], dict]] = field(default_factory=dict)
edges: dict[str, str] = field(default_factory=dict)
conditional: dict[str, tuple[Callable[[dict], str], dict[str, str]]] = field(default_factory=dict)
entry: str = ""
def add_node(self, name: str, fn: Callable[[dict], dict]) -> "StateGraph":
self.nodes[name] = fn
return self
def add_edge(self, src: str, dest: str) -> "StateGraph":
self.edges[src] = dest
return self
def add_conditional_edges(
self, src: str, router: Callable[[dict], str], mapping: dict[str, str]
) -> "StateGraph":
self.conditional[src] = (router, mapping)
return self
def set_entry(self, name: str) -> "StateGraph":
self.entry = name
return self
def run(self, state: dict, max_steps: int = 30) -> tuple[dict, list[str]]:
trace: list[str] = []
cur = self.entry
for _ in range(max_steps):
trace.append(cur)
if cur == END:
return state, trace
fn = self.nodes[cur]
update = fn(state)
state = {**state, **update}
if cur in self.conditional:
router, mapping = self.conditional[cur]
cur = mapping[router(state)]
elif cur in self.edges:
cur = self.edges[cur]
else:
cur = END
return state, trace
An agent that plans, acts, checks, and loops#
Four nodes:
plan: generate a next action (here: pick the tool with the first unmet requirement).
act: dispatch the action to a tool stub.
check: inspect the result; decide whether we’re done.
END.
This is the canonical shape for a “reasoning” agent - ReAct expressed as an explicit graph rather than a string-format loop.
# Toy task: an agent must gather three facts about a country before
# answering. State tracks which facts are already in `known`.
FACTS: dict[str, dict[str, str]] = {
"japan": {"capital": "Tokyo", "population": "125M", "currency": "Yen"},
"brazil": {"capital": "Brasilia", "population": "215M", "currency": "Real"},
"egypt": {"capital": "Cairo", "population": "110M", "currency": "Pound"},
}
REQUIRED = ["capital", "population", "currency"]
def plan_node(state: dict) -> dict:
known = state.get("known", {})
next_req = next((r for r in REQUIRED if r not in known), None)
return {"planned_tool": next_req}
def act_node(state: dict) -> dict:
tool = state["planned_tool"]
country = state["country"]
result = FACTS[country].get(tool, "UNKNOWN")
known = {**state.get("known", {}), tool: result}
return {"known": known, "last_result": result}
def check_node(state: dict) -> dict:
done = all(r in state.get("known", {}) for r in REQUIRED)
answer = None
if done:
answer = " / ".join(f"{k}: {state['known'][k]}" for k in REQUIRED)
return {"done": done, "answer": answer}
def route_after_check(state: dict) -> str:
return "end" if state["done"] else "loop"
graph = (
StateGraph()
.add_node("plan", plan_node)
.add_node("act", act_node)
.add_node("check", check_node)
.add_edge("plan", "act")
.add_edge("act", "check")
.add_conditional_edges("check", route_after_check, {"loop": "plan", "end": END})
.set_entry("plan")
)
final_state, trace = graph.run({"country": "japan", "known": {}})
print(f"trace: {' -> '.join(trace)}")
print(f"answer: {final_state.get('answer')}")
s.check(
"graph_terminates_within_budget",
lambda: trace[-1] == END and len(trace) < 30,
msg=f"trace length = {len(trace)}",
)
s.check(
"graph_visited_plan_three_times",
lambda: trace.count("plan") == len(REQUIRED),
msg=f"plan count = {trace.count('plan')}",
)
s.check(
"final_answer_contains_all_required_facts",
lambda: all(k in (final_state.get("answer") or "") for k in ("capital", "population", "currency")),
msg=f"answer = {final_state.get('answer')}",
)
s.check(
"first_visited_node_is_entry",
lambda: trace[0] == "plan",
msg=f"trace[0] = {trace[0]}",
)
# Run the graph on a second country to check state isolation.
final2, _ = graph.run({"country": "brazil", "known": {}})
s.check(
"state_resets_per_run",
lambda: "Brasilia" in (final2.get("answer") or ""),
msg=f"brazil answer = {final2.get('answer')}",
)
Visit counts and state progress#
The left bar chart counts how many times each node ran for the Japan query; plan/act/check each fire once per required fact, so the counts read off the conditional edge’s loop structure directly. The right panel tracks the size of state["known"] after each act step, showing the monotone climb to the termination condition. Nothing here is a new benchmark - both come from the single graph.run(...) already performed above.
import matplotlib.pyplot as plt
from collections import Counter
counts = Counter(n for n in trace if n != END)
nodes = ["plan", "act", "check"]
visits = [counts.get(n, 0) for n in nodes]
# Per-iteration: how many facts are known after each 'act' step.
known_over_time: list[int] = []
st = {"country": "japan", "known": {}}
cur = graph.entry
for _ in range(30):
if cur == END:
break
st = {**st, **graph.nodes[cur](st)}
if cur == "act":
known_over_time.append(len(st.get("known", {})))
if cur in graph.conditional:
r, m = graph.conditional[cur]; cur = m[r(st)]
else:
cur = graph.edges.get(cur, END)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(9, 3.2))
ax1.bar(nodes, visits, color=["tab:blue", "tab:orange", "tab:green"])
ax1.set_ylabel("visits per run"); ax1.set_title(f"node visit counts (trace len {len(trace)})")
for i, v in enumerate(visits):
ax1.text(i, v + 0.05, str(v), ha="center")
ax2.plot(range(1, len(known_over_time) + 1), known_over_time, marker="o", color="tab:purple")
ax2.axhline(len(REQUIRED), color="tab:gray", linestyle=":", label=f"target {len(REQUIRED)}")
ax2.set_xlabel("act iteration"); ax2.set_ylabel("facts known")
ax2.set_title("state progress over iterations"); ax2.legend(); ax2.grid(True, alpha=0.3)
fig.tight_layout(); plt.show()
Exercises#
Add a retry node. If a tool returns “UNKNOWN”, the
checkrouter should go to aretrynode that falls back to a more expensive search tool, then back tocheck. Draw the graph.Parallel branches. Real LangGraph supports parallel fan-out and gather. Extend
StateGraphwithadd_parallel(src, [n1, n2, n3], merge_fn)and redo the fact-gathering with three concurrent tool calls.Real LangGraph.
pip install langgraphand express the same agent withlanggraph.graph.StateGraph. Compare the shape.
References#
LangGraph documentation, Conceptual Guide and State management sections.
langgraph-prebuiltfor common agent templates (create_react_agent, etc.).
s.summary()
s.save()