Cycles and Loops
Rebuild Module 1’s ReAct loop as a LangGraph cycle - agent -> tools -> agent - with llama3 deciding and a step limit that actually stops it.
What you will be able to do
- Explain what a cycle is and why agents need one
- Build the agent -> tool -> agent loop with an edge back and a conditional exit
- Map Thought, Action and Observation onto nodes, state and edges
- Give a loop a stopping condition and a step limit - and know what happens without them
- Run a real ReAct cycle with llama3 choosing the actions
- Read a loop’s run step by step to find wasted or wrong steps
The idea, in plain English
Module 1’s agent was a for loop: ask the model, parse an action, run the tool, append the observation, repeat until a final answer or max_steps. A graph expresses the same thing with one extra edge: add_edge("tools", "agent") sends every tool result back to the agent. That edge back is a cycle.
A cycle alone runs forever. What makes it a loop you can trust is a conditional edge after the agent - should_continue(state) - with exits: an answer goes to END, too many steps goes to a give-up node, anything else goes to the tools. The state carries what the loop needs to decide: the history of actions and observations, the step count, the answer once there is one.
We built it twice. First with a hard-coded agent, to see the mechanics. Then with llama3 choosing each action through a schema-enforced Step (Lesson 2.14’s approach, since llama3 cannot call tools). Asked "Should I bring an umbrella in Mumbai, and what’s 12 * 8?", it called get_weather, then calculate, then answered: "Yes, bring an umbrella in Mumbai, and the result of 12 * 8 is 96."
The real runs also showed what loops do wrong: llama3 repeated calls it had already made, and once passed the calculator the wrong expression. The graph executed every step faithfully. A loop is only as good as its exits, its limits, and what you check inside it. LangGraph 1.2.14 throughout.
Worked example: The ReAct loop from Module 1, rebuilt as a graph cycle.
1 - Thought and action: the weather
The agent node shows llama3 the question and the (empty) history. It returns action get_weather, input Mumbai. should_continue sees no answer and few steps: go to tools.
"Should I bring an umbrella in Mumbai, and what’s 12 * 8?" - llama3 chooses each action; the tools node runs it. Three agent steps, two tool runs, 4.2 seconds.
From a for loop to a cycle
Module 1 wrote the loop as code: for step in range(max_steps): ask the model, stop on a final answer, parse the action, run the tool, append the observation. The loop lived inside a function, and the only way to see what it did was to print from inside it.
The graph version has the same parts, laid out as structure: an agent node (ask the model), a tools node (run what it asked for), a conditional edge after the agent (answer, limit, or another round), and a plain edge from tools back to agent. Nothing about ReAct changed. What changed is that the loop is now something you can draw, stream, and extend.
for step in range(max_steps)The cycle agent -> tools -> agent, plus a step count in state.response = model(...)The agent node.if final answer: returnshould_continue routes to END.observation = run_tool(action)The tools node.messages.append(observation)The tools node returns the longer history.Thought / Action / ObservationAgent output / action in state / observation in history.The mechanics, without a model
The first version uses a hard-coded agent: with no observation yet it asks for calculate; with one it writes the answer. The calculator returns "30". Streaming the run with stream_mode="updates" shows the cycle exactly: agent -> {step 1, action calculate}, calculator -> {observation "30"}, agent -> {step 2, answer "The calculation result is 30"}.
The agent runs twice because it has to see the tool’s result. That is the whole idea of ReAct: think, act, observe, think again.
Every loop needs an exit and a limit
A cycle plus a stopping condition is a loop; a cycle without one is a runaway. The stopping condition is should_continue: answer present -> END. The limit is a step count in state, incremented by the agent, checked by the same router.
We tested both failures. An agent that never answers, with MAX_STEPS = 5, ran agent -> calculator four times and stopped on the fifth agent step - with an empty answer. Better to route to a give_up node that says so: "Stopped after 6 steps without an answer." With no limit at all, LangGraph’s own guard caught it - after 10,007 node runs, the default recursion_limit. With a real model that is around five thousand LLM calls before anything stops.
Watch out: LangGraph’s default recursion_limit is 10,007. Treat it as a crash barrier, not a step limit: count steps in state, route to a give-up node, and pass a recursion_limit sized to your loop.
A real ReAct cycle with llama3
The agent node asks llama3 for a Step - action ("calculate", "get_weather" or "final_answer") and action_input - with structured output, so the reply is always a valid action. The tools node looks the action up and runs it: the model decides, the program executes. The observation goes into the history the agent sees next time.
The umbrella question took exactly the path a person would: get_weather("Mumbai") -> Rainy, 27°C; calculate("12 * 8") -> 96; then the answer. Mixed questions worked the same way - weather in Hyderabad, then the arithmetic. Each run took about 4 seconds.
What the real runs got wrong
Repetition. For "What is 125 * 48?" llama3 called calculate("125 * 48"), got 6000, and called it again before answering; for the Delhi weather it called get_weather three times. The loop still ended, but every repeat is a wasted model call and a wasted tool run. A small guard in the tools node fixed most of it: if the exact call is already in the history, do not run it again - reply "you already have this result (Sunny, 34°C); answer now". Delhi dropped from three weather calls to one.
A wrong argument. For "(250 - 37) * 4" llama3 sent calculate("250 - 37 * 4") - brackets dropped. The calculator correctly returned 102, and the model answered "the result of (250 - 37) * 4 is 102". Every node did its job; the answer is wrong. No loop guard catches that - it needs a check on what the model sends, or a test set of questions with known answers.
Umbrella in Mumbai, and 12 * 8?get_weather -> calculate -> answer. Correct.What is 125 * 48?calculate twice, then 6000. Correct, one wasted round.Weather in Delhi?get_weather three times, then Sunny, 34°C. Correct, two wasted rounds - one with the repeat guard.Raining in Hyderabad, and (250 - 37) * 4?Sent "250 - 37 * 4", answered 102. Wrong - the right answer is 852.What is 17 * 23 + 9?calculate twice, then 400. Correct.Debugging a loop
Stream the run and read it as a sequence of state changes. For each step ask: which node ran, what did it change, which route did should_continue pick, and why. Every trip around the cycle should move the state closer to an answer - a step that does not (the same call, the same observation) is the first thing to fix.
Keep the loop in the graph, not inside a node. A while True inside the agent node hides exactly what streaming would show you, and you do not need recursion either: the edge back is the loop.
Cycles beyond agents
The same shape covers retries (call -> failed -> attempts < 3 -> call), validation (generate -> validate -> fix -> validate), review (draft -> review -> revise -> review) and planning (plan -> execute -> observe -> re-plan). Each needs the same three parts: work, a decision, and an exit - plus a count so the exit is guaranteed.
Lesson 3.5 replaces the hand-written tools node with LangGraph’s prebuilt ToolNode.
Step-by-step code
from typing import TypedDict
from langgraph.graph import END, START, StateGraph
class State(TypedDict):
question: str
step: int
action: str
observation: str
answer: str
MAX_STEPS = 5
def agent(state: State):
step = state["step"] + 1
if state["observation"]:
return {"step": step, "answer": f"The calculation result is {state['observation']}"}
return {"step": step, "action": "calculate"}
def calculator(state: State):
return {"observation": str(10 + 20)}
def should_continue(state: State):
if state["answer"]:
return "end"
if state["step"] >= MAX_STEPS:
return "end"
return "tool"
builder = StateGraph(State)
builder.add_node("agent", agent)
builder.add_node("calculator", calculator)
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", should_continue, {"tool": "calculator", "end": END})
builder.add_edge("calculator", "agent") # the edge back - this is the cycle
graph = builder.compile()
start = {"question": "What is 10 + 20?", "step": 0, "action": "", "observation": "", "answer": ""}
for update in graph.stream(start, stream_mode="updates"):
print(update)
# {'agent': {'step': 1, 'action': 'calculate'}}
# {'calculator': {'observation': '30'}}
# {'agent': {'step': 2, 'answer': 'The calculation result is 30'}}An agent that never answers, MAX_STEPS = 5
agent -> calculator -> agent -> calculator -> agent -> calculator -> agent -> calculator -> agent
stopped at step 5, answer: '' <- stopped, but says nothing
The same agent, no step check at all
GraphRecursionError: Recursion limit of 10007 reached without hitting a stop condition.
10007 node runs first
graph.invoke(start, {"recursion_limit": 6})
GraphRecursionError: Recursion limit of 6 reached without hitting a stop condition.from typing import Literal, TypedDict
from langchain_ollama import ChatOllama
from langgraph.graph import END, START, StateGraph
from pydantic import BaseModel, Field
TOOLS = {"calculate": calculate, "get_weather": get_weather} # Lesson 2.13's safe tools
MAX_STEPS = 6
class Step(BaseModel):
action: Literal["calculate", "get_weather", "final_answer"]
action_input: str = Field(description="The expression, the city name, or the final answer text")
decide = ChatOllama(model="llama3.1", temperature=0).with_structured_output(Step)
SYSTEM = (
"You answer questions using two tools: get_weather(city) and calculate(expression). "
"Choose ONE next action. Use a tool for each piece of information you do not have yet. "
"When the Observations contain everything needed, reply with final_answer."
)
class AgentState(TypedDict):
question: str
history: list
step: int
action: str
action_input: str
answer: str
def agent(state: AgentState):
transcript = "\n".join([f"Question: {state['question']}"] + state["history"])
step = decide.invoke([("system", SYSTEM), ("human", transcript)])
update = {"step": state["step"] + 1, "action": step.action, "action_input": step.action_input}
if step.action == "final_answer":
update["answer"] = step.action_input
return update
def tools(state: AgentState):
call = f"Action: {state['action']}({state['action_input']!r})"
if call in state["history"]: # the same call again
done = state["history"][state["history"].index(call) + 1]
note = f"Observation: you already have this result ({done[13:]}). Do not repeat it; answer now if you can."
return {"history": state["history"] + [call, note]}
result = TOOLS[state["action"]](state["action_input"]) # your code runs the tool
return {"history": state["history"] + [call, f"Observation: {result}"]}
def give_up(state: AgentState):
return {"answer": f"Stopped after {state['step']} steps without an answer."}
def should_continue(state: AgentState) -> Literal["tools", "give_up", "__end__"]:
if state["answer"]:
return END
if state["step"] >= MAX_STEPS:
return "give_up"
return "tools"
builder = StateGraph(AgentState)
builder.add_node("agent", agent)
builder.add_node("tools", tools)
builder.add_node("give_up", give_up)
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", should_continue, ["tools", "give_up", END])
builder.add_edge("tools", "agent")
builder.add_edge("give_up", END)
graph = builder.compile()
question = "Should I bring an umbrella in Mumbai, and what's 12 * 8?"
start = {"question": question, "history": [], "step": 0, "action": "", "action_input": "", "answer": ""}
print(graph.invoke(start, {"recursion_limit": 20})["answer"])Should I bring an umbrella in Mumbai, and what's 12 * 8?
agent[get_weather('Mumbai')] -> tools: Rainy, 27°C
agent[calculate('12 * 8')] -> tools: 96
agent[final] "Yes, bring an umbrella in Mumbai, and the result of 12 * 8 is 96."
What's the weather in Delhi?
without the repeat guard: get_weather('Delhi') x3, then the answer
with the guard: get_weather('Delhi') -> agent repeats it -> "you already have this result" -> answer
Is it raining in Hyderabad, and what is (250 - 37) * 4?
agent[get_weather('Hyderabad')] -> tools: Cloudy, 29°C
agent[calculate('250 - 37 * 4')] -> tools: 102 <- the model dropped the brackets
agent[final] "... the result of (250 - 37) * 4 is 102." <- wrong: it is 852
graph.get_graph() edges
__start__ --> agent; agent -.-> tools; agent -.-> give_up; agent -.-> __end__;
tools --> agent; give_up --> __end__;Tip: Pass a recursion_limit sized to your loop - two nodes per round, plus a little: with MAX_STEPS = 6, recursion_limit=20 is ample and still stops a bug long before 10,007.
Loops at a glance
The edge backMakes the cycle.
builder.add_edge("tools", "agent")should_continueExit, give up, or go round again.
add_conditional_edges("agent", should_continue, [...])Step count in stateYour own limit, checked by the router.
if state["step"] >= MAX_STEPS: return "give_up"
give_up nodeAn honest answer when the limit is hit.
return {"answer": "Stopped after ..."}recursion_limitLangGraph’s hard stop - default 10,007.
graph.invoke(state, {"recursion_limit": 20})GraphRecursionErrorRaised when the hard stop is hit.
from langgraph.errors import GraphRecursionError
Try it yourself
The code does not change. Swap the content string and the program does something else entirely.
“Stream the hard-coded graph with stream_mode="updates" and count the agent runs.”
“Make the agent never answer. Run it with MAX_STEPS, then without, then with recursion_limit=6.”
“Add a third tool to the llama3 graph - a currency converter - and ask a question that needs all three.”
“Ask the llama3 graph about (250 - 37) * 4 several times. Add a check that compares the expression with the numbers in the question.”
What usually goes wrong
A router that can only return the loop node never ends. Every cycle needs a route to END.
✗ def should_continue(state):
return "tools"✓ def should_continue(state):
if state["answer"]:
return END
...A correct exit can still be missed by a confused model. Without a count, LangGraph runs 10,007 nodes before stopping.
Ending at the limit with an empty answer leaves the user with nothing. Route to a node that explains.
✗ if state["step"] >= MAX_STEPS:
return END✓ if state["step"] >= MAX_STEPS:
return "give_up" # writes "Stopped after N steps"A while True inside the agent node defeats the graph - nothing to stream, nothing to extend. The edge back is the loop.
Observations come from the tools node, never from the model - the lesson from Module 1 and 2.14.
llama3 turned (250 - 37) * 4 into 250 - 37 * 4. The loop worked and the answer was wrong. Validate inputs and test with known answers.
Key points
- A cycle is an edge back to an earlier node: add_edge("tools", "agent").
- ReAct maps directly: agent node (think), tools node (act), history in state (observe).
- A conditional edge after the agent decides: END, give_up, or another round.
- Count steps in state and route to a give-up node - LangGraph’s own limit is 10,007.
- With llama3 deciding, the umbrella question ran weather -> calculate -> answer.
- Real loops repeat calls; a repeat guard in the tools node cut Delhi from three weather calls to one.
- A correct loop can still give a wrong answer: the model sent 250 - 37 * 4.
Quick check before you move on
Quiz
- 1.
In the hard-coded example, why does the agent node run twice?
- 2.
An agent hits MAX_STEPS and the router returns END. What is wrong with that?
- 3.
What did the repeat guard change?
- 4.
The loop answered (250 - 37) * 4 with 102. Which part failed?
Interview questions
How does a ReAct agent map onto LangGraph?
An agent node calls the model, a tools node executes the chosen action and records the observation in state, an edge leads from tools back to the agent, and a conditional edge after the agent exits to END when there is an answer.
How do you prevent an infinite agent loop?
A logical exit in the router, a step count in state with a give-up route, and a recursion_limit sized to the loop as the hard stop - the default is 10,007.
How would you implement a retry workflow?
A node that does the work and records success and an attempt count; a conditional edge that routes success onward, failure with attempts left back to the node, and failure without attempts to an escalation node.
Why keep the loop in the graph rather than in a while loop inside a node?
The graph makes every step visible - streamable, traceable, checkpointable - and lets you add nodes such as validation or human approval inside the loop without rewriting it.
A loop finishes but gives a wrong answer. Where do you look?
At each step’s state update: which action and arguments the model chose and what the tool returned. In our run the model sent the wrong expression; the fix is input validation and a set of test questions with known answers.
Comments
Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.
Loading comments...
AI
System Design
Backend
- GraphQL8 modules · 69 lessons planned
- Core Python13 modules · 75 lessons planned
- FastAPI5 sections · 20 lessons
- Node.js14 modules · 206 lessons planned
- Node.js Performance7 chapters · 36 topics
- Event Loop Lifecycle6 phases · 3 scenarios
- Docker & Containerization11 modules · 144 lessons planned
- AWS for Developers14 modules · 219 lessons planned
- CI/CD & DevOps Automation10 modules · 134 lessons planned