Agentic AI

Agent Frameworks

LangGraph

LCEL chains are directed and acyclic — a | b | c flows one way. An agent loop is a cycle: think, act, observe, think again.

JrCodex·6 min read

Jr Codex Agentic AI Notes

Level: Intermediate–Advanced Prerequisites: Chapter 2: LangChain Time to complete: ~25 minutes


Table of Contents

  1. Why a Graph
  2. State and Reducers
  3. Nodes and Edges
  4. Building the Agent Loop
  5. Checkpointing and Resumption
  6. Human-in-the-Loop Interrupts
  7. Summary & Next Steps

1. Why a Graph

LCEL chains are directed and acyclic — a | b | c flows one way. An agent loop is a cycle: think, act, observe, think again.

The Shape Mismatch
─────────────────────────────────────────
  LCEL CHAIN            AGENT
    a ──► b ──► c         ┌──► agent ──┐
                          │      │     │
    acyclic, one pass     │      ▼     │
                          └── tools ◄──┘

                          cyclic, conditional,
                          unknown length
─────────────────────────────────────────
What LangGraph Adds
─────────────────────────────────────────
  CYCLES            loops with conditional exits

  SHARED STATE      a typed object every node reads
                    and updates, rather than passing
                    values along a pipe

  CHECKPOINTING     state persisted after every
                    node, so runs survive crashes
                    and can pause for humans

  The third is the one you cannot easily build
  yourself, and the main reason to adopt it
  (Chapter 1).
─────────────────────────────────────────

2. State and Reducers

The graph is organised around one typed state object.

from typing import Annotated, TypedDict
from langgraph.graph.message import add_messages
import operator
 
class AgentState(TypedDict):
    messages: Annotated[list, add_messages]      # REDUCER: appends rather than replaces
    plan: dict                                   # no reducer: assignment REPLACES
    step_count: Annotated[int, operator.add]     # REDUCER: sums the updates
    findings: Annotated[list, operator.add]      # REDUCER: concatenates lists
Reducers Are the Concept to Get Right
─────────────────────────────────────────
  A node returns a PARTIAL update, not the whole
  state. The reducer decides how it merges.

  NO REDUCER      the returned value REPLACES the
                  field. Correct for a plan or a
                  status.

  add_messages    appends, and de-duplicates by
                  message id. Correct for history.

  operator.add    concatenates lists or sums
                  numbers. Correct for accumulating
                  findings or counters.

  Get this wrong and you either lose history
  (replacing when you meant to append) or grow
  without bound (appending when you meant to
  replace). It is the most common LangGraph bug.
─────────────────────────────────────────
Why Reducers Matter for Parallelism
─────────────────────────────────────────
  When two branches run concurrently and both
  update `findings`, the reducer defines how the
  two results combine.

  Without one, the second write silently overwrites
  the first — and parallel work quietly disappears.
─────────────────────────────────────────

3. Nodes and Edges

The Vocabulary
─────────────────────────────────────────
  NODE               a function: state ──► partial
                     state update

  EDGE               unconditional: after A, go to B

  CONDITIONAL EDGE   a function inspects state and
                     returns the name of the next
                     node — this is where cycles and
                     branching live

  START, END         entry and terminal markers
─────────────────────────────────────────
from langgraph.graph import StateGraph, START, END
 
def call_model(state: AgentState) -> dict:
    response = model_with_tools.invoke(state["messages"])
    return {"messages": [response], "step_count": 1}      # PARTIAL update; reducers merge
 
def call_tools(state: AgentState) -> dict:
    outputs = []
    for call in state["messages"][-1].tool_calls:
        result = TOOLS[call["name"]].invoke(call["args"])
        outputs.append(ToolMessage(content=str(result), tool_call_id=call["id"]))
    return {"messages": outputs}
 
def should_continue(state: AgentState) -> str:
    """The conditional edge — this function IS the loop's exit condition."""
    if state["step_count"] >= 15:                          # Module 4's hard bound
        return "end"
    return "tools" if state["messages"][-1].tool_calls else "end"

4. Building the Agent Loop

builder = StateGraph(AgentState)
 
builder.add_node("agent", call_model)
builder.add_node("tools", call_tools)
 
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", should_continue,
                              {"tools": "tools", "end": END})
builder.add_edge("tools", "agent")            # THE CYCLE — back for another decision
 
graph = builder.compile()
The Whole Agent, as a Picture
─────────────────────────────────────────
  START ──► agent ──(tool_calls?)──► tools ──┐
              ▲                              │
              └──────────────────────────────┘
              │
              └──(no tool_calls, or budget)──► END
─────────────────────────────────────────
result = graph.invoke({"messages": [("user", "Investigate order ord_991")],
                       "step_count": 0, "findings": [], "plan": {}})
 
# Or watch it run, node by node:
for chunk in graph.stream(initial_state, stream_mode="updates"):
    for node, update in chunk.items():
        print(f"[{node}] {update}")
Why the Explicit Graph Is Worth the Verbosity
─────────────────────────────────────────
  The control flow is now DATA, not buried in a
  while-loop.

  It can be visualised, tested node by node,
  extended with a new node without touching the
  others, and — critically — CHECKPOINTED between
  any two nodes.
─────────────────────────────────────────

5. Checkpointing and Resumption

The capability that justifies the framework.

from langgraph.checkpoint.sqlite import SqliteSaver
 
with SqliteSaver.from_conn_string("agent_state.db") as checkpointer:
    graph = builder.compile(checkpointer=checkpointer)
 
    config = {"configurable": {"thread_id": "investigation-991"}}
 
    graph.invoke({"messages": [("user", "Investigate order ord_991")]}, config)
 
    # ... the process crashes, or the user closes the tab, or a day passes ...
 
    # Resume: state is reloaded from the checkpointer automatically.
    graph.invoke({"messages": [("user", "Continue where you left off")]}, config)
 
    # Inspect history — every checkpoint, in order:
    for snapshot in graph.get_state_history(config):
        print(snapshot.next, snapshot.values["step_count"])
What Checkpointing Gives You
─────────────────────────────────────────
  DURABILITY      a run survives a process restart.
                  For a 40-step agent, this is the
                  difference between an experiment
                  and a product.

  TIME TRAVEL     replay from any prior checkpoint —
                  the only practical way to debug a
                  non-deterministic multi-step run.

  FORKING         resume from an earlier state with
                  a different input, to compare
                  paths.

  PAUSE/RESUME    the foundation of the interrupts
                  in Section 6.

  Module 6, Chapter 2 develops all four.
─────────────────────────────────────────

6. Human-in-the-Loop Interrupts

graph = builder.compile(
    checkpointer=checkpointer,
    interrupt_before=["tools"],          # PAUSE before executing any tool
)
 
state = graph.invoke(initial, config)              # runs until it wants a tool, then stops
 
pending = graph.get_state(config).values["messages"][-1].tool_calls
print("about to run:", pending)
 
if approved(pending):
    graph.invoke(None, config)                     # None = RESUME from the checkpoint
else:
    graph.update_state(config, {"messages": [      # inject a correction instead
        ToolMessage(content="Denied by operator: refunds over $500 need a manager.",
                    tool_call_id=pending[0]["id"])]})
    graph.invoke(None, config)                     # the agent continues, now informed
# More precisely: interrupt only for DESTRUCTIVE tools (Module 2, Chapter 3's metadata).
def route_tools(state) -> str:
    calls = state["messages"][-1].tool_calls
    if any(TOOLS[c["name"]].metadata.get("destructive") for c in calls):
        return "approval"                          # a node that interrupts
    return "tools"                                 # safe tools proceed unattended
The Design Point
─────────────────────────────────────────
  Interrupting before EVERY tool makes an agent
  useless — a human approves forty reads to reach
  one write.

  Interrupt on the property that matters:
  destructiveness. Read-only tools run freely;
  mutations stop for a human.

  Module 8, Chapter 2 builds this into a full
  approval design.
─────────────────────────────────────────

7. Summary & Next Steps

Key Takeaways

  • An agent loop is cyclic and conditional, which LCEL chains cannot express — LangGraph exists to make control flow explicit data rather than a hidden while-loop.
  • Reducers define how a node's partial update merges into shared state; choosing wrongly either loses history or grows it without bound, and is the most common bug.
  • Checkpointing after every node delivers durability, time-travel debugging, forking and pause/resume — the capability that most justifies adopting a framework.
  • Interrupt on tool destructiveness rather than on every tool, or human approval becomes the bottleneck that makes the agent pointless.

Concept Check

  1. Why can't an agent loop be expressed as an LCEL chain?
  2. Two parallel branches both append to findings, but only the second branch's results survive. What is wrong?
  3. Why is interrupt_before=["tools"] a poor production default, and what should replace it?

Next Chapter

Chapter 4: AutoGen & CrewAI


Jr Codex — 1-on-1 Personalized Coaching | Back to Module Index | Back to Agentic AI Index