Module 1: Anatomy of an AI Agent

3. Cognitive architecture: perceive-reason-act

Capsule overview

The perceive-reason-act cognitive architecture is the fundamental loop of every agent. In the previous capsule you defined an agent's components (perception, reasoning, action, memory). Now you're going to understand how those components interact in a cycle: the agent perceives the current state, reasons about what to do, acts by running tools, observes the result, and decides whether to repeat the cycle or stop.

This pattern was formalized by the ReAct paper (Yao et al., 2022), which showed that combining reasoning (Thought) with action (Action) and observation (Observation) produces more effective agents than reasoning alone or acting alone. The ReAct loop is the building block of this guide — everything that comes later (state machines, planning, multi-agent) is built on top of it.

In this capsule you'll implement the loop manually: no create_react_agent, no abstractions. Just you, the model API, tools, and the cycle. That forces you to understand every step — and it prepares you to design custom loops in Module 4 when you need full control.


The perceive-reason-act loop

Diagram of the cycle

 ┌───────────────────────────────────────────────────────────────┐
 │                                                               │
 │   ┌─────────────┐                                             │
 │   │  PERCEIVE    │  The agent receives:                       │
 │   │              │  - User input (HumanMessage)               │
 │   │              │  - Previous results (ToolMessage)          │
 │   │              │  - Accumulated state (history)             │
 │   └──────┬───────┘                                            │
 │          ↓                                                    │
 │   ┌─────────────┐                                             │
 │   │  REASON      │  The LLM analyzes context and decides:     │
 │   │              │  - "I need more info → call a tool"        │
 │   │              │  - "I have enough → answer"                │
 │   └──────┬───────┘                                            │
 │          ↓                                                    │
 │   ┌─────────────┐                                             │
 │   │  ACT         │  If there are tool_calls:                  │
 │   │              │  - Run the tools                           │
 │   │              │  - Capture the results                     │
 │   └──────┬───────┘                                            │
 │          ↓                                                    │
 │   ┌─────────────┐                                             │
 │   │  OBSERVE     │  Append results as ToolMessage             │
 │   │              │  Context grows with every iteration        │
 │   └──────┬───────┘                                            │
 │          ↓                                                    │
 │       Done?                                                   │
 │      /       \                                                │
 │    No         Yes → Return the final answer                   │
 │     ↓                                                         │
 │   Back to PERCEIVE                                            │
 │                                                               │
 └───────────────────────────────────────────────────────────────┘

Each step in detail

PERCEIVE: On every iteration, the agent "sees" all the accumulated messages. On the first iteration that's just the user's input. On the second, it also includes the AIMessage with tool_calls and the ToolMessages with results. The context grows with every turn of the loop.

REASON: The LLM processes all the messages and produces a response. If it needs more information, it generates tool_calls — specific instructions about which tool to call and with what arguments. If it already has enough information, it generates text (content) with no tool_calls.

ACT: The system (not the LLM) runs the requested tools. The LLM doesn't execute code — it only decides what to call. The actual execution is the runtime's job.

OBSERVE: The tool results are packaged as ToolMessages and appended to the history. On the next iteration, the LLM will "see" them as part of its context.


Relationship to the ReAct paper

The original paper

ReAct: Synergizing Reasoning and Acting in Language Models (Yao et al., 2022) showed that LLMs improve when they combine explicit reasoning with actions:

ApproachWhat it doesLimitation
Reasoning only (Chain-of-Thought)The model thinks step by step but doesn't actIt can't verify facts or reach current data
Acting onlyThe model runs tools without making its reasoning explicitOpaque decisions, hard to debug
ReActThe model thinks AND acts in a loopBetter accuracy, more interpretable, more controllable

The Thought-Action-Observation format

In the paper, the agent explicitly generates three kinds of output:

Thought: I need to know the weather in Madrid to answer the user.
Action: get_weather(city="Madrid")
Observation: Weather in Madrid: 22°C, sunny

Thought: I now have the weather. Next I need to compute 15*23.
Action: calculator(expression="15*23")
Observation: 345

Thought: I have all the information. I can answer.
Answer: The weather in Madrid is 22°C and sunny, and 15 × 23 = 345.

ReAct in modern practice

In 2025-2026, models don't need to generate "Thought:" explicitly — the reasoning happens internally. Instead of text in a Thought/Action/Observation format, modern models use function calling: they return structured tool_calls (JSON) instead of text.

# The modern ReAct format isn't text — it's structured tool_calls:
response = model_with_tools.invoke(messages)

# Instead of "Action: get_weather(city='Madrid')", you get:
# response.tool_calls = [
#   {"name": "get_weather", "args": {"city": "Madrid"}, "id": "call_abc"}
# ]

The concept is the same: reason → act → observe → repeat. Only the communication format between the model and the system changed.

Why ReAct matters for this guide

The ReAct loop is the most common pattern in LLM agents. But it isn't the only one. Throughout this guide you'll see it evolve:

ModulePatternEvolution of the loop
1 (here)Basic ReActPerceive → Reason → Act → Observe
4State machineThe loop becomes a graph with nodes and edges
5Plan-and-ExecuteIt plans first, then executes — not a simple loop
5ReflectionAfter acting, it evaluates its own work and corrects itself
8Multi-agentMultiple ReAct loops coordinated by a supervisor

ReAct is the building block. The advanced patterns are variations and extensions of this same cycle. If you understand it well here, the rest of the guide feels natural.


Manual implementation of the ReAct loop

Basic version

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage, ToolMessage

@tool
def get_weather(city: str) -> str:
    """Get the current weather for a city. Example: get_weather('Madrid')"""
    climates = {"Madrid": "22°C, sunny", "Barcelona": "20°C, cloudy", "París": "15°C, rainy"}
    return f"Weather in {city}: {climates.get(city, '18°C, partly cloudy')}"

@tool
def calculator(expression: str) -> str:
    """Evaluate a math expression. Example: calculator('15 * 23')"""
    try:
        return str(eval(expression))
    except Exception as e:
        return f"Calculation error: {e}"

model = init_chat_model("openai:gpt-4.1-mini")
tools = [get_weather, calculator]
tools_by_name = {t.name: t for t in tools}
model_with_tools = model.bind_tools(tools)

def react_loop(user_input: str, max_iterations: int = 5) -> str:
    """Manual implementation of the ReAct loop."""
    messages = [HumanMessage(content=user_input)]
    
    for iteration in range(max_iterations):
        # REASON: the model analyzes the context and decides
        response = model_with_tools.invoke(messages)
        messages.append(response)
        
        # Done? If there are no tool_calls, the agent wants to answer
        if not response.tool_calls:
            return response.content or "No response"
        
        # ACT + OBSERVE: run the tools and capture the results
        for tc in response.tool_calls:
            tool_fn = tools_by_name[tc["name"]]
            result = tool_fn.invoke(tc["args"])
            messages.append(ToolMessage(content=result, tool_call_id=tc["id"]))
    
    return "Iteration limit reached with no final answer"

# Run it
result = react_loop("What's the weather in Barcelona and what's 17 * 23?")
print(result)

# Expected output:
# The weather in Barcelona is 20°C and cloudy. And 17 × 23 = 391.

Version with detailed logging

To understand exactly what happens on each iteration:

def react_loop_verbose(user_input: str, max_iterations: int = 5) -> str:
    """ReAct loop with step-by-step logging."""
    messages = [HumanMessage(content=user_input)]
    
    print(f"🎯 Input: {user_input}")
    print("=" * 60)
    
    for iteration in range(max_iterations):
        print(f"\n--- Iteration {iteration + 1} ---")
        print(f"📥 PERCEIVE: {len(messages)} messages in context")
        
        # REASON
        response = model_with_tools.invoke(messages)
        messages.append(response)
        
        if not response.tool_calls:
            print(f"🧠 REASON: Answer directly")
            print(f"✅ ANSWER: {response.content}")
            return response.content
        
        print(f"🧠 REASON: Call {len(response.tool_calls)} tool(s)")
        
        # ACT + OBSERVE
        for tc in response.tool_calls:
            tool_fn = tools_by_name[tc["name"]]
            result = tool_fn.invoke(tc["args"])
            messages.append(ToolMessage(content=result, tool_call_id=tc["id"]))
            print(f"🔧 ACT: {tc['name']}({tc['args']})")
            print(f"👁️ OBSERVE: {result}")
    
    return "Max iterations reached"

react_loop_verbose("Weather in Madrid and what's 25 * 4?")

# Expected output:
# 🎯 Input: Weather in Madrid and what's 25 * 4?
# ============================================================
#
# --- Iteration 1 ---
# 📥 PERCEIVE: 1 messages in context
# 🧠 REASON: Call 2 tool(s)
# 🔧 ACT: get_weather({'city': 'Madrid'})
# 👁️ OBSERVE: Weather in Madrid: 22°C, sunny
# 🔧 ACT: calculator({'expression': '25 * 4'})
# 👁️ OBSERVE: 100
#
# --- Iteration 2 ---
# 📥 PERCEIVE: 4 messages in context
# 🧠 REASON: Answer directly
# ✅ ANSWER: The weather in Madrid is 22°C and sunny. 25 × 4 = 100.

Notice how on iteration 2 the agent perceives 4 messages (HumanMessage + AIMessage with tool_calls + 2 ToolMessages). The context grows with every iteration — that's short-term memory in action.


Stop conditions: when to break the loop

The agent needs to know when to stop iterating. There are three main conditions:

1. Task complete (the model decides)

The most natural condition: the model returns content with no tool_calls, signaling it has enough information to answer.

if not response.tool_calls:
    return response.content  # The agent decided it's done

2. Max iterations (safety limit)

Protection against infinite loops. If the model doesn't converge after N iterations, the system stops.

MAX_ITERATIONS = 5
for i in range(MAX_ITERATIONS):
    # ... loop ...
    pass  # If it gets here, the limit was reached
return "I couldn't complete the task in the allowed time"

3. Critical error (unrecoverable failure)

If a tool fails in a way you can't recover from:

for tc in response.tool_calls:
    try:
        result = tools_by_name[tc["name"]].invoke(tc["args"])
    except KeyError:
        return f"Error: tool '{tc['name']}' does not exist"
    except Exception as e:
        result = f"Error in {tc['name']}: {str(e)}"
    messages.append(ToolMessage(content=result, tool_call_id=tc["id"]))

What to set max_iterations to

Task typeRecommended max_iterations
Simple task (1-2 tools)3-5
Medium task (3-5 tools)5-10
Research (many searches)10-15
Complex analysis (multi-step)15-25

A high max_iterations doesn't mean the agent always uses every iteration — only that it has headroom if the task calls for it.


Comparison: manual loop vs create_react_agent

AspectManual loopcreate_react_agent
ControlTotal — you see every stepAbstracted — you only see input/output
Code~25-30 lines~5 lines
CustomizationDirect — you edit the loopVia hooks and configuration
DebuggingEasy — drop prints wherever you wantVia LangSmith or callbacks
Stop conditionsYou implement your ownConfigurable recursion_limit
Error handlingYou design itThe framework's default handling
When to use itLearning, prototypes, debuggingProduction, when you don't need granular control

Recommendation: Learn with the manual loop (this guide starts there), use create_react_agent in production. When you need full control of the flow, use StateGraph (Module 4).


Connection with the project

In this module's project (capsule 08):

  • You'll implement exactly this manual loop with 2+ tools
  • You'll add stop conditions (max_iterations + task complete)
  • You'll compare it with the create_react_agent version

In the evolving project (Modules 4-10):

  • The perceive-reason-act loop becomes a StateGraph in Module 4: each step is a node, the transitions are edges
  • In Module 5, "Reason" expands: not just "which tool do I call?" but "what's my plan for this research?"
  • In Module 6, "Observe" persists: results are saved in checkpoints so you can resume later
  • In Module 8, multiple loops run in parallel: each agent has its own perceive-reason-act cycle

Troubleshooting

Problem 1: the agent falls into an infinite loop

Cause: The model always returns tool_calls and never decides to answer directly.

Fix: Check that the system prompt allows it to answer without tools. Add an instruction like "When you have enough information, answer the user directly."

from langchain_core.messages import SystemMessage

messages = [
    SystemMessage(content="You are a helpful assistant. Use tools when you need data. When you have enough information, answer directly."),
    HumanMessage(content=user_input)
]

Problem 2: the model ignores the tool results

Cause: The ToolMessage isn't being appended to the history correctly, or the tool_call_id doesn't match.

Fix: Check that every ToolMessage carries the exact tool_call_id:

# The id MUST match the one from the original tool_call
messages.append(ToolMessage(
    content=result,
    tool_call_id=tc["id"]  # This id comes from response.tool_calls
))

Problem 3: the agent repeats the same tool call

Cause: The model doesn't "remember" that it already called that tool because the ToolMessages aren't in the context.

Fix: Check that the ToolMessages are being appended to the messages array before the next model call.

Problem 4: high latency on every iteration

Cause: Each iteration makes a full LLM call with the entire accumulated context.

Fix: Use faster models for simple tasks (gpt-4.1-mini vs gpt-4.1), trim the history with message trimming, or cache results from tools that don't change.

Problem 5: the model calls tools that don't exist

Cause: The model "hallucinates" tool names that aren't in the list of available tools.

Fix: Validate that the tool name exists before running it:

for tc in response.tool_calls:
    if tc["name"] not in tools_by_name:
        messages.append(ToolMessage(
            content=f"Error: the tool '{tc['name']}' does not exist. Available tools: {list(tools_by_name.keys())}",
            tool_call_id=tc["id"]
        ))
        continue
    result = tools_by_name[tc["name"]].invoke(tc["args"])
    messages.append(ToolMessage(content=result, tool_call_id=tc["id"]))

Exercises

Exercise 1: trace the loop step by step (easy)

For the input "What's 25*4 and the weather in París?", trace the loop's iterations. Assume the model calls both tools in parallel on the first iteration.

View solution

Iteration 1:

  • PERCEIVE: 1 message → [HumanMessage("What's 25*4 and the weather in París?")]
  • REASON: the LLM returns 2 tool_calls → [calculator("25*4"), get_weather("París")]
  • ACT: calculator("25*4") → "100", get_weather("París") → "15°C, rainy"
  • OBSERVE: 2 ToolMessages are appended to the history

Iteration 2:

  • PERCEIVE: 4 messages → [HumanMessage, AIMessage(tool_calls), ToolMessage("100"), ToolMessage("15°C")]
  • REASON: the LLM has all the information, returns content with no tool_calls
  • Output: "25 × 4 = 100. The weather in París is 15°C and rainy."

Total: 2 iterations, 2 tool calls (parallel), 2 LLM calls.

Explanation: Modern models like GPT-4.1 do parallel function calling — they call multiple tools in a single iteration when the question calls for it. That reduces the number of iterations and the total latency.

Exercise 2: implement react_loop with 3 tools (easy)

Add a third tool, get_date(), to the loop. Try it with: "What day is it today, what's 100/4, and how's the weather in Madrid?"

View solution
from datetime import datetime

@tool
def get_date() -> str:
    """Get the current date and day of the week."""
    return datetime.now().strftime("%Y-%m-%d %A")

tools = [get_weather, calculator, get_date]
tools_by_name = {t.name: t for t in tools}
model_with_tools = model.bind_tools(tools)

result = react_loop("What day is it today, what's 100/4, and the weather in Madrid?")
print(result)

# Expected output (the model will likely call all 3 tools in parallel):
# Today is 2026-03-08 (Sunday). 100 ÷ 4 = 25. The weather in Madrid is 22°C and sunny.

Explanation: The model detects 3 independent sub-tasks and calls all 3 tools in a single iteration. The loop only needs 2 iterations: one for the tools and one for the final answer.

Exercise 3: detect repeated tool calls (medium)

Modify the loop so it detects whether the model is calling the same tool with the same arguments it already used. If it detects a repeat, stop the loop and tell the user.

View solution
def react_loop_no_repeat(user_input: str, max_iterations: int = 5) -> str:
    messages = [HumanMessage(content=user_input)]
    seen_calls = set()
    
    for _ in range(max_iterations):
        response = model_with_tools.invoke(messages)
        messages.append(response)
        
        if not response.tool_calls:
            return response.content
        
        for tc in response.tool_calls:
            call_signature = f"{tc['name']}:{tc['args']}"
            if call_signature in seen_calls:
                return f"Loop detected: {tc['name']} was already called with {tc['args']}. Stopping."
            seen_calls.add(call_signature)
            
            result = tools_by_name[tc["name"]].invoke(tc["args"])
            messages.append(ToolMessage(content=result, tool_call_id=tc["id"]))
    
    return "Max iterations"

Explanation: We keep a set of name:arguments for every tool call. If the model tries to repeat the exact same call, we detect the pattern and cut it off. That prevents loops where the model "forgets" it already obtained a piece of information.

Exercise 4: count approximate tokens (medium)

Modify react_loop_verbose so that at the end it reports: number of iterations, total number of tool calls, and total message length (as a proxy for tokens).

View solution
def react_loop_metrics(user_input: str, max_iterations: int = 5) -> dict:
    messages = [HumanMessage(content=user_input)]
    total_tool_calls = 0
    
    for iteration in range(max_iterations):
        response = model_with_tools.invoke(messages)
        messages.append(response)
        
        if not response.tool_calls:
            total_chars = sum(len(str(m.content)) for m in messages)
            return {
                "response": response.content,
                "iterations": iteration + 1,
                "tool_calls": total_tool_calls,
                "total_chars": total_chars,
                "messages": len(messages)
            }
        
        total_tool_calls += len(response.tool_calls)
        for tc in response.tool_calls:
            result = tools_by_name[tc["name"]].invoke(tc["args"])
            messages.append(ToolMessage(content=result, tool_call_id=tc["id"]))
    
    return {"response": "Max iterations", "iterations": max_iterations, "tool_calls": total_tool_calls}

metrics = react_loop_metrics("Weather in Madrid and what's 15*23?")
print(f"Iterations: {metrics['iterations']}")
print(f"Tool calls: {metrics['tool_calls']}")
print(f"Total messages: {metrics['messages']}")
print(f"Total characters: {metrics['total_chars']}")

Explanation: Total characters are a rough proxy for tokens (~4 characters = 1 token). That gives you visibility into the cost of each interaction. In production (Module 10), you'll use LangSmith for exact metrics.

Exercise 5: loop with a custom system prompt (hard)

Implement a loop where the system prompt tells the agent: "You are a travel assistant. You can only use the available tools. If you don't have a tool for the requested information, say so honestly." Try it with a question it can't answer (e.g., "How much does a flight to Tokio cost?").

View solution
from langchain_core.messages import SystemMessage

def travel_agent(user_input: str, max_iterations: int = 5) -> str:
    messages = [
        SystemMessage(content=(
            "You are a travel assistant. You can only use the available tools. "
            "If you don't have a tool for the requested information, say so honestly "
            "instead of making data up."
        )),
        HumanMessage(content=user_input)
    ]
    
    for _ in range(max_iterations):
        response = model_with_tools.invoke(messages)
        messages.append(response)
        
        if not response.tool_calls:
            return response.content
        
        for tc in response.tool_calls:
            result = tools_by_name[tc["name"]].invoke(tc["args"])
            messages.append(ToolMessage(content=result, tool_call_id=tc["id"]))
    
    return "Max iterations"

# A question it CAN answer
print(travel_agent("How's the weather in París?"))
# Output: "The weather in París is 15°C and rainy."

# A question it CANNOT answer
print(travel_agent("How much does a flight to Tokio cost?"))
# Output: "I don't have a tool to look up flight prices.
#          I'd suggest checking Google Flights or Skyscanner."

Explanation: The system prompt instructs the agent to be honest about its limits. A well-designed agent admits what it can't do. That's key in production: users prefer "I don't know" over made-up information.


Summary

In this capsule you learned:

  • The perceive-reason-act loop is the fundamental cycle of every agent: perceive → reason → act → observe → repeat
  • The ReAct paper formalized this pattern as Thought → Action → Observation
  • In modern practice, "Action" is implemented via function calling (tool_calls JSON), not text
  • Implementing the loop manually (~25 lines) gives you full visibility into every step
  • Stop conditions are critical: max_iterations (safety), task_complete (natural), error (failure)
  • The context grows with every iteration: more ToolMessages = more information to decide with
  • Modern models do parallel function calling: multiple tools in a single iteration

Next capsule: A taxonomy of agents — you'll learn to classify agents by type (reactive, deliberative, hybrid) and by complexity level (simple reflex, model-based, goal-based, utility-based).


Additional resources

  1. ReAct: Synergizing Reasoning and Acting in Language Models — The foundational paper that formalized the ReAct loop
  2. LangGraph create_react_agent Reference — API for the prebuilt agent that abstracts this loop
  3. OpenAI Function Calling Guide — How OpenAI models implement tool calling
  4. Anthropic Tool Use Documentation — Tool use implementation in Claude
  5. Toolformer Paper — Another foundational paper on LLMs that learn to use tools
  6. LangChain ReAct Agent Tutorial — Official step-by-step tutorial