Issue 04 · The agent loop

The agent loop,
step by step.

Reason, act, observe, repeat. The tool-calling loop can be run by hand, but here we let it run itself. We build the agent as a graph: a node that calls the model, a node that runs the tools, and one conditional edge that turns a straight line into a cycle. Then we watch it think through a problem, and learn how to stop it before it loops forever.

What you need going in: the tool-calling loop, an AIMessage with tool_calls, a ToolMessage with a matching tool_call_id, and the request-and-result round trip between them. This essay automates exactly that round trip, so it helps to be comfortable with the manual version first.
Ground truth: the framework code uses LangGraph's Graph API (StateGraph, nodes, edges, conditional routing) and LangChain's model and tool interfaces, taken from their documentation. The interactive agent runs a small deterministic stand-in for a model, so every thought, action, and observation is identical on each load. Spot something wrong? The colophon has my contact.
Step 1

1. The tool-calling loop is a graph

An agent is not a new idea. It is the tool-calling loop, drawn as a cycle and run automatically.

What is this? The tool-calling loop can be written by hand: send messages, check for tool_calls, run the tools, send the results back, repeat. An agent is that loop made autonomous. You hand it a model, some tools, and a goal; it cycles, reasoning and calling tools, until it can answer.

Why a graph? Straight-line code expresses neither of the two things an agent needs: branching (sometimes call a tool, sometimes answer) and cycles (keep going until done). A directed graph expresses both naturally. When you call create_agent, it builds exactly this: a graph with a model node, a tools node, and edges between them. You were using a graph all along.

This essay drops one level down, to the engine underneath create_agent: LangGraph. You define the nodes, the edges, and the state, and you get total control over what runs, in what order, and when it stops. The price is a little more code. The reward is that you can see, and change, every part of the loop.

An agent is a loop. A loop is a cycle in a graph. Build the graph and you have built the agent.

Step 2

2. Three primitives: nodes, edges, state

Everything in a graph is one of three things. Learn them once and the rest is composition.

Nodes do work. Each node is a function that reads the current state and returns an update: only the keys it wants to change, never the whole state.

Edges decide what is next. A fixed edge always goes to the same node. A conditional edge runs a small function that inspects the state and picks the destination at runtime. This is where branching and looping live.

State carries context. A typed schema (usually a TypedDict) defines what data every node can see. Reducers control how updates to the same key merge.

The smallest possible graph

Define state, write a node, wire it from START to END, compile, invoke. Every graph, however large, is this shape.

from langgraph.graph import StateGraph, START, END
from typing import TypedDict

class State(TypedDict):
    value: int

def double(state: State) -> dict:
    return {"value": state["value"] * 2}

graph = StateGraph(State)
graph.add_node("double", double)
graph.add_edge(START, "double")
graph.add_edge("double", END)
app = graph.compile()

print(app.invoke({"value": 5})["value"])   # 10

START and END are virtual nodes: the entry point and the exit. compile() validates the structure (every referenced node exists, every node is reachable, END can be reached) and returns a runnable app.

Nodes are what happens. Edges are where to go next. State is what everyone can see. Keep those three jobs separate and graphs stay easy to reason about.

Step 3

3. State is the agent's memory

A model has no memory. The graph's state is where the conversation lives and grows.

What is this? Every pass through the loop, the model sees only what is in state["messages"]. The agent's whole memory is that growing list. The trick is the reducer: by default a node's update replaces a field, but messages must accumulate. The add_messages reducer appends new messages instead of overwriting, and updates a message in place if it shares an id.

Why it matters. Without accumulation, each tool result would erase the conversation, and the model would loop with amnesia. The reducer is what lets the loop build context across iterations.

The code

from langgraph.graph import StateGraph, MessagesState, START, END
from typing import TypedDict, Annotated
from langgraph.graph.message import add_messages

# Written out, the chat state is just this:
class ChatState(TypedDict):
    messages: Annotated[list, add_messages]   # append, do not overwrite

# But this pattern is so common that LangGraph ships it as MessagesState.
# Subclass it when you need extra fields:
class AgentState(MessagesState):
    pass
MessagesState is a prebuilt schema equal to a single messages field with the add_messages reducer. Use it directly, or subclass it to add your own fields (a step counter, a scratchpad, a running score). Custom fields use the default overwrite behavior unless you give them their own reducer.

The agent's memory is a list of messages plus a reducer that grows it. That list is the only thing the model ever sees.

Step 4

4. The agent node: call the model

The first node. It hands the conversation to the model and appends whatever the model says back.

What is this? The agent node is where reasoning happens. It takes the current messages, calls the model (with tools bound), and returns the model's reply. That reply is an AIMessage. It either contains tool_calls (the model wants to act) or it does not (the model is ready to answer).

The code

from langchain.chat_models import init_chat_model

model = init_chat_model("openai:gpt-5.4")
model_with_tools = model.bind_tools(tools)

def agent(state: MessagesState) -> dict:
    """Call the model on the running conversation, append its reply."""
    response = model_with_tools.invoke(state["messages"])
    return {"messages": [response]}   # add_messages appends it

Notice the node returns {"messages": [response]}, a partial update of just the one key. The add_messages reducer appends the new AIMessage to the conversation rather than replacing it.

The agent node is one line of real work: invoke the model, append the reply. Whether that reply is a tool request or a final answer is what the next edge inspects.

Step 5

5. The tools node: execute the calls

The second node. It runs whatever the model asked for and appends the results.

What is this? When the agent node produces an AIMessage with tool_calls, the tools node runs them. It reads the last message, dispatches each call to the matching tool, and appends one ToolMessage per call, each tagged with its tool_call_id. Those results re-enter the conversation, and the loop heads back to the agent node to read them.

The code

# each tool's .name is its @tool function name
tools_by_name = {t.name: t for t in tools}

def tools_node(state: MessagesState) -> dict:
    """Run every tool the last AIMessage requested."""
    last = state["messages"][-1]
    results = []
    for call in last.tool_calls:
        chosen = tools_by_name[call["name"]]
        results.append(chosen.invoke(call))   # returns a ToolMessage
    return {"messages": results}
This is the same execution you would do by hand, now wrapped in a node. The overview said create_agent builds "a model node, a tools node, and edges between them": this is that tools node, written out.

The tools node is the "act" half of the loop. It turns the model's requests into real results and feeds them back into the state.

Step 6

6. The conditional edge that closes the loop

One small function turns two nodes into a cycle. This is the whole trick.

What is this? After the agent node runs, something has to decide: did the model ask for tools, or is it done? A conditional edge answers that. It is a lightweight router function that reads the last message and returns the name of the next node: if there are tool_calls, go to tools; otherwise, go to END. The tools node always routes back to agent. That back-edge is what makes it a loop.

Why a Literal return type? Annotating the router with Literal[...] is not decoration. LangGraph reads it at compile time to validate that every destination the router can return actually exists in the graph.

The code

from typing import Literal
from langgraph.graph import END

def should_continue(state: MessagesState) -> Literal["tools", "__end__"]:
    """If the model asked for tools, run them. Otherwise, finish."""
    last = state["messages"][-1]
    if last.tool_calls:
        return "tools"
    return END

LangGraph ships this exact router prebuilt, so in practice you can write:

from langgraph.prebuilt import tools_condition
# equivalent to should_continue: routes to tools if the last
# message has tool_calls, otherwise to END
graph.add_conditional_edges("agent", tools_condition)

The conditional edge is the heart of the agent. "Tools, or done?" asked after every model call, is what separates a one-shot answer from an autonomous loop.

Step 7

7. Assembling the loop

Two nodes, one entry, one conditional edge, one back-edge. That is a complete agent.

The graph

Read the wiring against the diagram below. START goes to agent. agent branches: to tools if it called tools, to END if it did not. tools always returns to agent. The cycle is agent → tools → agent.

START agent call the model tools execute the calls END has tool_calls no tool_calls observe
from langgraph.graph import StateGraph, MessagesState, START, END

graph = StateGraph(MessagesState)
graph.add_node("agent", agent)
graph.add_node("tools", tools_node)

graph.add_edge(START, "agent")                       # enter at the model
graph.add_conditional_edges("agent", should_continue) # tools, or END?
graph.add_edge("tools", "agent")                     # observe, then loop back

app = graph.compile()

result = app.invoke(
    {"messages": [{"role": "user", "content": "..."}]},
    {"recursion_limit": 25},                          # safety net, see Step 9
)
print(result["messages"][-1].content)

There is no magic in an agent. It is two nodes and one conditional edge. The single back-edge from tools to agent is the entire difference between a function and an agent.

Step 8

8. ReAct: reason, then act

The loop in motion. Watch the agent think, call a tool, read the result, and think again.

What is this? ReAct is the name for the pattern the loop produces: Reason then Act, interleaved. At each turn through the graph, the agent node reasons about what it knows and emits an action (a tool call); the tools node returns an observation; the agent reasons again with that new fact in hand. The chain of thought, action, observation, thought, action, observation continues until the agent has enough to answer.

Mapping to the graph. Each thought plus action is one run of the agent node, one super-step. Each observation is one run of the tools node, another super-step. The recursion limit counts those super-steps, which is why a long chain of reasoning can hit it.

Live: a multi-step task, one tool call at a time

Pick a task. The agent has two tools: lookup (a small fact table) and calculator. Watch it gather facts one at a time, each observation feeding the next thought, until it answers. The step meter counts super-steps against the recursion limit; lower the limit in Step 9 and the same task will halt early.

ReAct is not a special algorithm. It is what the loop looks like from the outside: reason, act, observe, repeat, with each cycle adding one hard fact to the agent's memory.

Step 9

9. Stopping the loop (before it loops forever)

A cycle with no exit runs until your budget is gone. Every agent needs a hard stop.

What is this? The loop ends naturally when the agent node returns a message with no tool_calls: the conditional edge sends it to END. But a model can misbehave: call the same tool forever, chase a goal it cannot reach, or oscillate between two actions. Without a ceiling, that burns tokens and money indefinitely.

The safety net. LangGraph caps the number of super-steps with recursion_limit. Exceed it and the graph raises GraphRecursionError instead of running on. Set it deliberately; it is the difference between a bug and an outage.

Live: shrink the budget, watch it halt

Same agent as Step 8. Lower the recursion limit below what the task needs, run it, and the loop stops mid-reasoning with a GraphRecursionError instead of finishing.

The code

from langgraph.errors import GraphRecursionError

try:
    app.invoke(
        {"messages": [{"role": "user", "content": "..."}]},
        {"recursion_limit": 6},      # too low for a long task
    )
except GraphRecursionError:
    print("Hit the recursion limit before finishing.")

Every way an agent stops

Stop conditionMechanismWhen it fires
Natural completionconditional edge to ENDThe agent returns a message with no tool_calls. The normal, desired ending.
Recursion limitrecursion_limit in config; raises GraphRecursionErrorSuper-step count exceeds the cap. The hard safety net for raw LangGraph.
Model-call limitModelCallLimitMiddleware(max_calls=N)For agents built with create_agent: caps total model calls. Always set in production.
Routing exita node returns Command(goto=END)A node decides, from its own logic, that the loop is done.

Always set a ceiling. The loop is powerful precisely because it can keep going, which is exactly why you must tell it when to stop.

Step 10

10. Workflows vs agents

Who decides the path: you, or the model? Most real systems use both.

Workflows have control flow you fix at design time. Conditional edges may pick among known paths, but the structure is decided by you. Predictable, testable, inflexible.

Agents have control flow the model drives. The graph is the skeleton; the model fills in the routing by deciding when to call tools and when to stop. Flexible, adaptive, unpredictable.

The hybrid wins. Pure agents can take ten steps or two, call the wrong tool, or loop. Pure workflows cannot adapt to novel input. Real systems wrap agent nodes (the reasoning parts) inside a deterministic workflow (validate input, check permissions, format output) so you get reliability where you need it and flexibility where you need it.

NeedReach forWhy
A model that calls tools until donecreate_agentthe built-in loop is all you need
A linear pipeline, no branchingplain Pythonno graph overhead needed
Conditional routing between known pathsLangGraphgraphs express branching directly
Cycles, iterative refinementLangGraphcycles are first-class
Human-in-the-loop, pause and resumeLangGraphbuilt-in persistence and interrupts
Custom state beyond messagesLangGraphtyped state with reducers

If the workflow is "call tools until done," use create_agent. Reach for raw LangGraph when you need branching, cycles, custom state, or a human in the loop. You can migrate incrementally: LangGraph is the engine underneath either way.

Step 11

11. Beyond the basic loop

The two-node loop is the foundation. A handful of primitives extend it to anything.

Command: route and update in one step. A node can return a Command(update=..., goto=...) to change state and choose the next node together, instead of splitting that across a node and a separate router. Good for triage nodes where the decision and the update are the same logic.

Send: dynamic fan-out. When a router returns a list of Send objects, LangGraph spawns one parallel copy of a node per item. This is map-reduce: fan out a variable number of sub-tasks, then collect their results through a reducer.

Conditional branching. The same conditional-edge mechanism that asks "tools or done?" can route to any number of specialized nodes based on state: classify, then branch to the right handler.

from langgraph.types import Command, Send

# route + update in one return
def triage(state) -> Command:
    if "urgent" in state["messages"][-1].content:
        return Command(update={"priority": "high"}, goto="escalate")
    return Command(update={"priority": "normal"}, goto="agent")

# fan out a variable number of parallel tasks
def fan_out(state) -> list[Send]:
    return [Send("worker", {"task": t}) for t in state["subtasks"]]

Nodes, edges, state, plus Command and Send for routing and fan-out. With those, the basic loop generalizes to routers, branches, parallel workers, and multi-agent systems.

Step 12

12. What we left out

Real machinery, deferred to keep this essay about the loop itself.

  • Persistence and threads. Compile with a checkpointer (graph.compile(checkpointer=...)) and the loop's state is saved after every super-step, so a conversation interrupted on Tuesday resumes on Thursday. The basis of multi-turn memory.
  • Human in the loop. interrupt_before pauses the graph at a chosen node to wait for a person to approve, edit, or inject information, then resumes.
  • Streaming. Stream the loop step by step (app.stream(..., stream_mode="updates")) so a user watches the agent work instead of waiting for the final answer.
  • Node runtime. Nodes can take a richer signature (state, config, runtime) to reach injected context, a cross-thread store, and a stream writer, without globals.
  • The super-step model. LangGraph runs in Pregel-style super-steps: nodes activated together execute together, and parallel branches run in the same super-step. This is why two nodes wired to the same predecessor run at once.
  • Subgraphs and multi-agent. A whole graph can be a node in a larger graph, which is how supervisor-and-worker agent teams are built.

The loop you built here grows its message list every iteration, and eventually that list outgrows the model's context window. Deciding what to keep, drop, and summarize as that happens is context engineering, a craft of its own and the natural next thing to learn.

The loop is small and the extensions are few. Persistence, interrupts, streaming, and subgraphs are the same graph with more wired in, not a different idea.