Module 1: Anatomy of an AI Agent

2. What an AI agent is (formal definition)

Capsule overview

An AI agent is a system that perceives its environment, reasons about what to do to reach a goal, and acts on the environment using tools. The difference from a chatbot is fundamental: a chatbot generates text in response to input; an agent makes decisions, executes actions, and observes results in an iterative loop until it reaches its goal.

This capsule formally defines the four components of an agent: perception, reasoning, action, and memory. Without that conceptual clarity, you'll confuse agents with deterministic pipelines or workflows. With it, you'll be able to design systems that actually behave like agents — and more importantly, you'll be able to spot when something sold as an "agent" isn't one.

The most useful analogy: a chatbot is like an employee who answers questions from their desk. An agent is like an employee who gets up, looks things up on the computer, makes calls, reads documents, and hands you a complete result — deciding at each step what to do next.


Formal definition

Agent = an autonomous system that operates in an iterative loop of:

  1. Perceive (perception) — Receives input from the environment: user messages, results from previous tools, current state
  2. Reason (reasoning) — An LLM decides what action to take based on the goal and the accumulated context
  3. Act (action) — Executes external tools (search, computation, API calls) that modify or query the environment
  4. Observe (observation) — Receives the result of the action and decides whether to continue the loop or stop
┌──────────────────────────────────────────────────────────────────┐
│                                                                  │
│  CHATBOT                                                         │
│  ────────────────────────────────────                            │
│  User Input  →  LLM  →  Text Output                              │
│  (a single pass, no tools, no iteration)                         │
│                                                                  │
│                                                                  │
│  AGENT                                                           │
│  ────────────────────────────────────                            │
│  User Input  →  ┌──────────────────────────────┐  →  Output      │
│                 │  Reason (the LLM decides)    │                 │
│                 │       ↓                      │                 │
│                 │  Act (execute a tool)        │                 │
│                 │       ↓                      │                 │
│                 │  Observe (read the result)   │                 │
│                 │       ↓                      │                 │
│                 │  Done? No → repeat           │                 │
│                 └──────────────────────────────┘                 │
│  (iterative loop with external tools)                            │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

The key is the loop. An agent doesn't answer in a single pass — it iterates until it reaches its goal or until a stop condition halts it.


The four components of an agent

1. Perception

The agent receives information from the environment. In the LLM context, the "environment" is the messages the model receives:

  • HumanMessage — Direct input from the user
  • ToolMessage — Results from tools executed earlier
  • SystemMessage — Behavior instructions
  • AIMessage — The agent's own previous responses (with or without tool_calls)
from langchain_core.messages import HumanMessage, AIMessage, ToolMessage, SystemMessage

# The agent perceives all of this as its "environment":
messages = [
    SystemMessage(content="You are a research assistant. Use tools to answer."),
    HumanMessage(content="What's the weather in Madrid and what day is it today?"),
    # After the first iteration, the agent also perceives:
    # AIMessage(content="", tool_calls=[...]),
    # ToolMessage(content="22°C, sunny", tool_call_id="call_abc123"),
]

The agent's perception is cumulative: with every iteration of the loop, the agent sees more information (the results of the tools it already ran). That lets it make better decisions.

2. Reasoning

The LLM analyzes the current context and decides what to do:

  • Option A: Answer directly (it has enough information)
  • Option B: Call a tool (it needs more information, or needs to execute an action)
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool

@tool
def get_weather(city: str) -> str:
    """Get the current weather for a city."""
    return f"Weather in {city}: 22°C, sunny"

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

model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools([get_weather, get_date])

# The model reasons and decides to call tools
response = model_with_tools.invoke(messages)

# If the model decides to use tools:
print(response.tool_calls)
# Output: [
#   {"name": "get_weather", "args": {"city": "Madrid"}, "id": "call_abc"},
#   {"name": "get_date", "args": {}, "id": "call_def"}
# ]

Reasoning is the piece that makes the agent "intelligent". The LLM doesn't run fixed rules — it evaluates the context and dynamically decides what to do. That's what separates an agent from a script.

3. Action

When the model decides to call tools, the system executes those tools and captures the results:

# Run each tool the model asked for
for tool_call in response.tool_calls:
    tool_name = tool_call["name"]
    tool_args = tool_call["args"]
    
    if tool_name == "get_weather":
        result = get_weather.invoke(tool_args)
    elif tool_name == "get_date":
        result = get_date.invoke(tool_args)
    
    # Build a ToolMessage with the result
    tool_message = ToolMessage(
        content=result,
        tool_call_id=tool_call["id"]
    )
    messages.append(tool_message)

# Output:
# ToolMessage(content="Weather in Madrid: 22°C, sunny", tool_call_id="call_abc")
# ToolMessage(content="2026-03-08 Sunday", tool_call_id="call_def")

Tools are what give the agent hands. Without tools, an LLM only generates text. With tools, it can search the internet, query databases, run calculations, send emails — interact with the real world.

4. Memory

The state the agent keeps between iterations and between sessions:

Short-term memory (within one conversation):

  • The message history: every HumanMessage, AIMessage, and ToolMessage accumulates
  • The agent "remembers" which tools it already used and what results it got
  • It's lost when the session ends

Long-term memory (across sessions):

  • User preferences saved in a persistent store
  • Facts learned from past interactions
  • Requires extra infrastructure (databases, stores)
# Short-term: the message history IS the memory
messages = [
    HumanMessage(content="Weather in Madrid?"),
    AIMessage(content="", tool_calls=[{"name": "get_weather", "args": {"city": "Madrid"}, "id": "call_1"}]),
    ToolMessage(content="22°C, sunny", tool_call_id="call_1"),
    AIMessage(content="The weather in Madrid is 22°C and sunny."),
    HumanMessage(content="And in Barcelona?"),
    # The agent "remembers" that you already asked about Madrid
]

Memory is the component you'll explore in depth in Module 6. For now, understand that without memory every interaction starts from zero — and that severely limits how useful an agent can be.

How the components interact

The four components don't operate in isolation — they form a cycle:

     ┌─────────────┐
     │  PERCEPTION  │ ← User input, ToolMessages, state
     └──────┬──────┘
            ↓
     ┌─────────────┐
     │  REASONING   │ ← The LLM analyzes and decides
     └──────┬──────┘
            ↓
     ┌─────────────┐
     │   ACTION     │ ← Runs the selected tools
     └──────┬──────┘
            ↓
     ┌─────────────┐
     │   MEMORY     │ ← Stores results, updates state
     └──────┬──────┘
            ↓
          Done?
         /     \
       Yes      No → back to PERCEPTION
        ↓
     Final output

On each iteration, the agent perceives more (the results of previous tools), reasons with more context, and decides with more information. It's a process of progressive refinement — not a single-pass execution.


Comparison: chatbot vs agent vs pipeline

AspectChatbotPipeline/ChainAgent
FlowInput → OutputInput → A → B → C → OutputInput → [Loop] → Output
ToolsNoFixed (hardcoded)Dynamic (the LLM chooses)
DecisionsOne passPredeterminedThe model decides at each step
IterationNoNo (linear)Yes (loops until done)
VerificationNoNoYes (it observes results)
PredictabilityHighVery highLow (the model chooses the path)
CostLow (1 LLM call)Medium (N fixed calls)High and variable (N dynamic calls)

When to choose each one?

  • Chatbot: When you only need conversational answers with no external actions
  • Pipeline: When the flow is predictable and always runs the same steps
  • Agent: When the LLM needs to dynamically decide what to do and which tools to use

Full example: an agent step by step

Let's walk through the full flow of an agent answering "What's the weather in Barcelona and what's 15 * 23?":

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

# --- STEP 1: Define the tools ---
@tool
def get_weather(city: str) -> str:
    """Get the current weather for a city."""
    return f"Weather in {city}: 22°C, sunny"

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

# --- STEP 2: Wire the model up with the tools ---
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)

# --- STEP 3: The agent loop ---
messages = [
    HumanMessage(content="What's the weather in Barcelona and what's 15 * 23?")
]

MAX_ITERATIONS = 5
for i in range(MAX_ITERATIONS):
    # PERCEIVE + REASON: the model sees the messages and decides
    response = model_with_tools.invoke(messages)
    messages.append(response)
    
    # Any tool_calls? If not, the agent is done
    if not response.tool_calls:
        print(f"Final answer (iteration {i+1}):")
        print(response.content)
        break
    
    # ACT: run each tool
    for tc in response.tool_calls:
        tool_result = tools_by_name[tc["name"]].invoke(tc["args"])
        # OBSERVE: append the result as a ToolMessage
        messages.append(ToolMessage(content=tool_result, tool_call_id=tc["id"]))
        print(f"  Tool: {tc['name']}({tc['args']}) → {tool_result}")

# Expected output:
#   Tool: get_weather({'city': 'Barcelona'}) → Weather in Barcelona: 22°C, sunny
#   Tool: calculator({'expression': '15 * 23'}) → 345
#   Final answer (iteration 2):
#   The weather in Barcelona is 22°C and sunny. And 15 × 23 = 345.

This code implements a complete agent without using create_react_agent. Every step of the loop is visible: perceive (messages), reason (model invoke), act (run tools), observe (ToolMessage). In capsule 08 (the project) you'll do this same thing but with more tools and stop conditions.


Example with create_react_agent

The same agent, but with the LangGraph abstraction:

from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langgraph.prebuilt import create_react_agent
from langchain_core.tools import tool

@tool
def get_weather(city: str) -> str:
    """Get the current weather for a city."""
    return f"Weather in {city}: 22°C, sunny"

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

model = init_chat_model("openai:gpt-4.1-mini")
agent = create_react_agent(model, [get_weather, calculator])

result = agent.invoke({"messages": [("user", "Weather in Barcelona and what's 15*23?")]})
print(result["messages"][-1].content)

# Expected output:
# The weather in Barcelona is 22°C and sunny. And 15 × 23 = 345.

create_react_agent wraps the entire loop you wrote by hand: perception, reasoning, action, observation, and the stop condition. It's more concise but less visible — that's why in this guide you implement it manually first.


Connection with the project

In this module's project (capsule 08) you'll implement a ReAct agent from scratch:

  • The formal definition gives you the mental framework to design the loop
  • The components (perception, reasoning, action, memory) define what your implementation must have
  • The chatbot vs agent comparison helps you verify that your project really is an agent (it has a loop + tools + observation)

In the evolving project (Research Agent, Modules 4-10):

  • The components map directly onto StateGraph nodes: perceive → reason → act → observe
  • Memory will expand from short-term (messages) to long-term (PostgresSaver, BaseStore) in Module 6

Troubleshooting

Problem 1: the agent doesn't use tools

Cause: The tool descriptions (docstrings) are vague or don't guide the model.

Fix:

# ❌ Bad: vague description
@tool
def search(query: str) -> str:
    """Search for something."""
    ...

# ✅ Good: specific description with an example
@tool
def search(query: str) -> str:
    """Search the internet. Use it for questions about current facts.
    Example: search('weather in Madrid today')"""
    ...

Problem 2: infinite loop

Cause: The agent has no clear stop condition.

Fix: Define max_iterations and check on each iteration whether the agent already has enough information.

MAX_ITERATIONS = 5
for i in range(MAX_ITERATIONS):
    response = model_with_tools.invoke(messages)
    if not response.tool_calls:
        break  # The agent decided to answer, so stop

Problem 3: the agent answers without using tools when it should

Cause: The system prompt doesn't instruct the agent to use tools.

Fix: Include explicit instructions in the system prompt:

system = "You are a research assistant. ALWAYS use the available tools to get up-to-date data. Do not make up information."

Problem 4: ToolMessage with the wrong id

Cause: The ToolMessage's tool_call_id doesn't match the id of the tool_call.

Fix: Make sure each ToolMessage uses the exact id of the tool_call it's answering:

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"]  # Must match exactly
    ))

Exercises

Exercise 1: identify the components (easy)

Given this code, identify which line corresponds to each agent component (perception, reasoning, action, observation):

messages = [HumanMessage(content="What time is it?")]           # Line A
response = model_with_tools.invoke(messages)                     # Line B
result = get_time.invoke(response.tool_calls[0]["args"])         # Line C
messages.append(ToolMessage(content=result, tool_call_id=..))    # Line D
View solution
  • Line A → Perception: The agent receives the user's input
  • Line B → Reasoning: The LLM analyzes the messages and decides what to do (call get_time)
  • Line C → Action: The tool runs with the arguments the model chose
  • Line D → Observation: The tool result is added to the context for the next iteration

Explanation: The full loop would be: A → B → C → D → B (again) → final answer. The agent reasons again after observing, deciding whether it needs more tools or can already answer.

Exercise 2: is it an agent? (easy)

Decide whether each system is an agent, a chatbot, or a pipeline:

  1. A system that receives an email and always runs: classify → extract data → save to DB
  2. A system that receives a question and calls GPT-4 to answer directly
  3. A system that receives a question, decides whether to search the web or the database, runs the search, evaluates the result, and decides whether it needs to search more
View solution
  1. Pipeline — The flow is always the same (classify → extract → save), there's no dynamic decision by the LLM
  2. Chatbot — A single pass with no tools and no iteration
  3. Agent — There's a dynamic decision (web vs DB), tool execution, observation of results, and the possibility of iterating

Explanation: The key is to ask: does the LLM dynamically decide what to do? Is there an action-observation loop? If yes to both, it's an agent.

Exercise 3: implement a minimal agent (medium)

Implement a manual agent (no create_react_agent) with two tools: greet(name), which greets someone, and multiply(a, b), which multiplies two numbers. The agent should answer: "Greet Ana and multiply 7 by 8".

View solution
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 greet(name: str) -> str:
    """Greet a person by name. Example: greet('Ana')"""
    return f"Hi, {name}! Welcome."

@tool
def multiply(a: int, b: int) -> str:
    """Multiply two numbers. Example: multiply(7, 8)"""
    return str(a * b)

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

messages = [HumanMessage(content="Greet Ana and multiply 7 by 8")]

for i in range(5):
    response = model_with_tools.invoke(messages)
    messages.append(response)
    
    if not response.tool_calls:
        print(response.content)
        break
    
    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"]))

Explanation: The model receives the question, decides to call both tools (possibly in parallel), receives the results, and generates a final answer combining them. The loop only needs 2 iterations.

Exercise 4: add a stop condition (medium)

Modify the agent from exercise 3 so that it stops if a tool returns an error. If multiply receives non-numeric arguments, the agent should report the error and stop.

View solution
@tool
def multiply(a: int, b: int) -> str:
    """Multiply two numbers. Example: multiply(7, 8)"""
    try:
        return str(a * b)
    except Exception as e:
        return f"ERROR: {e}"

# In the loop, detect errors:
for i in range(5):
    response = model_with_tools.invoke(messages)
    messages.append(response)
    
    if not response.tool_calls:
        print(response.content)
        break
    
    has_error = False
    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"]))
        if result.startswith("ERROR:"):
            has_error = True
    
    # The model will see the error in the ToolMessage and can decide to report it
    # You don't need to force the stop: the LLM can decide to surface the error

Explanation: Pydantic validates the types of a and b in the tool schema, so the model will generally send integers. But if there's an error, the ToolMessage with "ERROR:" tells the LLM something failed, and on the next iteration it can report the error to the user.

Exercise 5: count the iterations (hard)

Implement an agent that solves: "What's the weather in Madrid, Barcelona, and Valencia?" with a get_weather(city) tool. Log how many loop iterations it needed and how many tool calls it made. Did the model call the 3 cities in parallel or sequentially?

View solution
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", "Valencia": "25°C, clear"}
    return f"Weather in {city}: {climates.get(city, '15°C, unknown')}"

model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools([get_weather])

messages = [HumanMessage(content="What's the weather in Madrid, Barcelona, and Valencia?")]

total_tool_calls = 0
for iteration in range(10):
    response = model_with_tools.invoke(messages)
    messages.append(response)
    
    if not response.tool_calls:
        print(f"\nLoop iterations: {iteration + 1}")
        print(f"Total tool calls: {total_tool_calls}")
        print(f"Answer: {response.content}")
        break
    
    total_tool_calls += len(response.tool_calls)
    print(f"Iteration {iteration + 1}: {len(response.tool_calls)} tool calls")
    
    for tc in response.tool_calls:
        result = get_weather.invoke(tc["args"])
        messages.append(ToolMessage(content=result, tool_call_id=tc["id"]))

# Typical output:
# Iteration 1: 3 tool calls (parallel!)
# Loop iterations: 2
# Total tool calls: 3

Explanation: GPT-4.1-mini will typically do parallel function calling — it calls get_weather for all 3 cities in a single iteration. That's more efficient than 3 sequential iterations. Parallel calls are a pattern you'll explore in depth in Module 3.


Summary

In this capsule you learned:

  • An agent is a system with an iterative loop: perceive → reason → act → observe → repeat
  • The 4 components are: Perception (input), Reasoning (the LLM decides), Action (run tools), Memory (persistent state)
  • The difference from a chatbot is the loop + tools: a chatbot does a single pass, an agent iterates until it's done
  • The difference from a pipeline is the dynamic decision: a pipeline runs fixed steps, an agent chooses what to do
  • In LangChain, create_react_agent wraps the entire loop, but you can implement it manually in ~20 lines
  • tool_calls are the mechanism: the model returns which tool to call and with what arguments, the system runs it and returns results as a ToolMessage

Next capsule: Cognitive architecture: perceive-reason-act — you'll go deeper into the fundamental loop, its relationship to the ReAct paper, and implement it step by step from scratch.


Additional resources

  1. LangChain Agents Documentation — Official agent reference in LangChain
  2. ReAct: Synergizing Reasoning and Acting (Paper) — The paper that formalized the Reasoning + Acting loop
  3. Russell & Norvig: Intelligent Agents — Chapter 2 of "Artificial Intelligence: A Modern Approach"
  4. OpenAI Function Calling Guide — Official function calling documentation
  5. LangGraph create_react_agent — API reference for the prebuilt agent
  6. Building Effective Agents (Anthropic) — Anthropic's perspective on agent design