Module 1: Anatomy of an AI Agent

4. A Taxonomy of Agents

Capsule description

Not all agents are the same. An agent that answers "What's 2+2?" by calling calculator and returning the result has nothing in common with one that plans a 5-step market research project, runs searches, evaluates the quality of each source, and decides whether it needs to replan. Both are agents — but their level of deliberation, their complexity, and their cost are radically different.

This capsule gives you two complementary classification systems. The first is the academic taxonomy from Russell & Norvig (AIMA): simple reflex → model-based → goal-based → utility-based, organized by increasing sophistication. The second is the practical taxonomy you'll use day to day: reactive, deliberative, and hybrid. The first gives you precise vocabulary for design; the second gives you the judgment to decide what to build.

The reality is that nearly every LLM agent you build in production is hybrid: reactive when the task is simple, deliberative when it's complex. Understanding the full taxonomy lets you deliberately choose where on the spectrum your agent sits — instead of it landing there by accident.


Academic Taxonomy: Russell & Norvig

The classic classification of agents comes from Artificial Intelligence: A Modern Approach (Russell & Norvig). It defines four progressive levels of sophistication in how an agent makes decisions.

Level 1: Simple Reflex Agent

The most basic agent. It responds directly to the current input with a fixed rule. No memory, no world model, no planning. It's a direct mapping: condition → action.

Current input → Condition-action rule → Action
def simple_reflex_agent(user_input: str) -> str:
    """Agent that responds with if/else rules, no LLM and no memory."""
    user_input_lower = user_input.lower()
    
    if "weather" in user_input_lower:
        city = user_input.split("in ")[-1].strip("?")
        return f"[get_weather] Checking weather in {city}..."
    
    if "calculate" in user_input_lower or "how much" in user_input_lower:
        return "[calculator] Processing calculation..."
    
    return "I don't have a rule for that kind of question."

print(simple_reflex_agent("What is the weather in Madrid?"))
print(simple_reflex_agent("How much is 15 * 23?"))
print(simple_reflex_agent("Research the impact of AI on education"))

# Expected output:
# [get_weather] Checking weather in Madrid...
# [calculator] Processing calculation...
# I don't have a rule for that kind of question.

The problem is obvious: if the user says "Is it hot in Barcelona?" instead of "weather in Barcelona", the rule fails. There's no reasoning — just static pattern matching.

Where it shows up in practice: Almost never as a standalone agent. But it does appear as a component: a router deciding "if the input is about weather, send it to weather_agent; if it's about code, send it to code_agent." That router is a simple reflex agent.

Level 2: Model-Based Reflex Agent

This adds an internal model of the world — state that persists across interactions. Decisions depend on the current input + what the agent "remembers."

Current input + Internal state (world model) → Action
from langchain.chat_models import init_chat_model
from langchain_core.messages import HumanMessage, SystemMessage

def model_based_agent(user_input: str, history: list) -> tuple[str, list]:
    """Agent that uses conversation history as its world model."""
    model = init_chat_model("openai:gpt-4.1-mini")
    messages = [
        SystemMessage(content="You are a helpful assistant. Use the prior context to answer."),
        *history,
        HumanMessage(content=user_input)
    ]
    response = model.invoke(messages)
    history.append(HumanMessage(content=user_input))
    history.append(response)
    return response.content, history

history = []
resp, history = model_based_agent("My name is Carlos and I work in fintech", history)
print(f"Turn 1: {resp}")
resp, history = model_based_agent("Which AI tools do you recommend for me?", history)
print(f"Turn 2: {resp}")

# Expected output:
# Turn 1: Hi Carlos! Great that you work in fintech...
# Turn 2: For fintech, I'd recommend... [contextualized to fintech]

On turn 2, the agent remembers that Carlos works in fintech. A simple reflex agent would treat each turn as independent.

Where it shows up in practice: Any chatbot with conversation history is model-based. It's the most common type of "basic agent" with an LLM.

Level 3: Goal-Based Agent

This adds explicit goals. The agent has a defined objective and plans a sequence of actions to reach it. The decision about which action to take depends on whether that action moves the agent closer to its goal.

Goal + Current state → Plan (sequence of actions) → Execute step by step
from dotenv import load_dotenv
load_dotenv()

from langchain.chat_models import init_chat_model
from langchain_core.messages import HumanMessage, SystemMessage

def goal_based_agent(goal: str) -> str:
    """Agent that decomposes a goal into a plan and executes it."""
    model = init_chat_model("openai:gpt-4.1-mini")
    
    # Phase 1: Generate the plan
    plan_response = model.invoke([
        SystemMessage(content="Break the task into 3-5 concrete steps. Numbered list only."),
        HumanMessage(content=f"Goal: {goal}")
    ])
    plan = plan_response.content
    print(f"Generated plan:\n{plan}\n")
    
    # Phase 2: Execute each step sequentially
    results = []
    for i, step in enumerate(plan.strip().split("\n"), 1):
        if not step.strip():
            continue
        step_result = model.invoke([
            SystemMessage(content="Execute the given step and produce a concise result."),
            HumanMessage(content=f"Step: {step}\nPrior context: {results[-1] if results else 'None'}")
        ])
        results.append(step_result.content)
        print(f"Step {i} completed: {step.strip()[:60]}...")
    
    return results[-1] if results else "No results"

result = goal_based_agent("Research the 3 main applications of AI in healthcare")

# Expected output:
# Generated plan:
# 1. Identify the main application areas of AI in healthcare
# 2. Research AI-powered medical imaging diagnosis
# 3. Research AI-driven drug discovery
# 4. Synthesize findings into a comparative summary
#
# Step 1 completed: 1. Identify the main application areas of AI in health...
# Step 2 completed: 2. Research AI-powered medical imaging diagnosis...
# ...

The plan first, execute second structure is what makes it goal-based. That pattern (plan-and-execute) is the heart of Module 5.

Where it shows up in practice: Research agents, agents that write long documents, agents that manage projects — any task that requires decomposition into steps with sequential execution.

Level 4: Utility-Based Agent

The most sophisticated level. The agent has a utility function that evaluates how good each possible action is. It picks the action that maximizes expected value, weighing cost, latency, probability of success, and quality.

State + Possible actions → Evaluate the utility of each → Pick the optimal one
def utility_based_agent(query: str, tools: list[dict]) -> str:
    """Agent that picks the best tool by evaluating cost/benefit."""
    best_tool = None
    best_score = -1
    
    for t in tools:
        relevance = 1.0 if any(kw in query.lower() for kw in t["keywords"]) else 0.3
        cost_score = 1.0 - t["cost_normalized"]
        speed_score = 1.0 - t["latency_normalized"]
        
        # Utility function: configurable weights
        utility = (relevance * 0.4) + (cost_score * 0.2) + (speed_score * 0.2) + (t["accuracy"] * 0.2)
        
        print(f"  {t['name']}: utility = {utility:.2f}")
        if utility > best_score:
            best_score = utility
            best_tool = t["name"]
    
    return best_tool

tools = [
    {"name": "tavily_search", "keywords": ["search", "current", "trends"], "cost_normalized": 0.2, "latency_normalized": 0.4, "accuracy": 0.85},
    {"name": "wikipedia", "keywords": ["what is", "definition", "history"], "cost_normalized": 0.0, "latency_normalized": 0.2, "accuracy": 0.7},
    {"name": "arxiv_search", "keywords": ["paper", "research", "study"], "cost_normalized": 0.0, "latency_normalized": 0.6, "accuracy": 0.95},
]

selected = utility_based_agent("Search for the current trends in quantum computing", tools)
print(f"\nSelected tool: {selected}")

# Expected output:
#   tavily_search: utility = 0.81
#   wikipedia: utility = 0.50
#   arxiv_search: utility = 0.47
#
# Selected tool: tavily_search

Where it shows up in practice: In multi-tool systems where not all options are equal. An agent with 15 tools needs to choose intelligently — don't call the expensive one when the cheap one solves it just as well. It also shows up in model routing: do I use GPT-4.1 (expensive, precise) or GPT-4.1-mini (cheap, fast)?

Visual progression

Level 1 (Simple Reflex):   Input → [if/else] → Action
Level 2 (Model-Based):     Input + State → [Decision] → Action
Level 3 (Goal-Based):      Goal + State → [Plan] → [Step 1] → [Step 2] → Result
Level 4 (Utility-Based):   State + Options → [Evaluate utility] → Optimal action

Practical Taxonomy: Reactive vs Deliberative vs Hybrid

Russell & Norvig's taxonomy is precise but academic. In the day-to-day of AI Engineering, you'll use a more direct classification based on how much the agent thinks before acting.

Reactive Agents

A reactive agent makes decisions turn by turn with no explicit planning. It takes input, decides which tool to use, and that's it. It's the ReAct loop you implemented in the previous capsule.

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, SystemMessage, ToolMessage

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

@tool
def calculator(expression: str) -> str:
    """Calculates a mathematical expression."""
    try:
        return str(eval(expression))
    except Exception as e:
        return f"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 reactive_agent(user_input: str, max_iterations: int = 3) -> str:
    """Purely reactive agent: decides tool by tool, with no plan."""
    messages = [HumanMessage(content=user_input)]
    for i 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 "Iteration limit reached"

print(reactive_agent("What's the weather in Madrid and how much is 12 * 7?"))

# Expected output:
# The weather in Madrid is 22°C and sunny. And 12 × 7 = 84.

Strengths: Low latency, simple to implement, predictable for simple tasks.

Weaknesses: For complex tasks with dependencies between steps, it can take suboptimal paths because it never planned.

Deliberative Agents

A deliberative agent plans before acting. It first decomposes the task, then executes the plan, and can re-plan if something fails.

# Assumes the imports and model from the previous example

@tool
def web_search(query: str) -> str:
    """Searches the web for up-to-date information."""
    return f"Results for '{query}': relevant data found."

def deliberative_agent(task: str) -> str:
    """Agent that plans first and executes afterward."""
    tools_list = [web_search, calculator]
    tools_map = {t.name: t for t in tools_list}
    bound_model = model.bind_tools(tools_list)
    
    # PHASE 1: Plan
    plan = model.invoke([
        SystemMessage(content="Break the task into numbered steps. The list only."),
        HumanMessage(content=f"Task: {task}")
    ]).content
    print(f"=== PLAN ===\n{plan}\n")
    
    # PHASE 2: Execute following the plan
    messages = [
        SystemMessage(content=f"Execute this plan step by step:\n{plan}"),
        HumanMessage(content=f"Task: {task}")
    ]
    for _ in range(10):
        response = bound_model.invoke(messages)
        messages.append(response)
        if not response.tool_calls:
            return response.content
        for tc in response.tool_calls:
            result = tools_map[tc["name"]].invoke(tc["args"])
            messages.append(ToolMessage(content=result, tool_call_id=tc["id"]))
            print(f"Executed: {tc['name']}({list(tc['args'].values())[0][:40]}...)")
    return "Timeout"

print(deliberative_agent("Research AI in healthcare and produce a summary"))

# Expected output:
# === PLAN ===
# 1. Search for a general overview of AI in healthcare
# 2. Search for AI in medical imaging diagnosis
# 3. Synthesize the findings into a summary
#
# Executed: web_search(AI healthcare 2026...)
# Executed: web_search(AI imaging diagnosis...)
# [Synthesized summary]

Strengths: Better for complex tasks with dependent steps, more controllable (you can inspect the plan before executing), and it can re-plan when a step fails.

Weaknesses: Higher latency (at minimum 1 extra LLM call to plan), overkill for simple tasks.

Hybrid Agents

The most useful type in production. A hybrid agent decides dynamically whether to act reactively or deliberatively based on the complexity of the input. Simple questions → reactive (fast). Complex tasks → deliberative (plan first).

# Uses the same imports and tools from the previous examples (search, calculator)

def classify_complexity(user_input: str) -> str:
    """Classifies whether a task needs planning or is reactive."""
    response = model.invoke([
        SystemMessage(content="""Classify as SIMPLE or COMPLEX.
SIMPLE: 1-2 tools, direct answer.
COMPLEX: multiple steps, research, or synthesis.
Reply ONLY with SIMPLE or COMPLEX."""),
        HumanMessage(content=user_input)
    ])
    return response.content.strip().upper()

def hybrid_agent(user_input: str) -> str:
    """Agent that chooses between reactive and deliberative mode."""
    complexity = classify_complexity(user_input)
    print(f"Complexity: {complexity}")
    
    if "SIMPLE" in complexity:
        print("→ Mode: REACTIVE")
        return reactive_agent(user_input)  # Reuses the reactive_agent defined above
    
    else:
        print("→ Mode: DELIBERATIVE")
        # Plan first, then execute with tools
        plan = model.invoke([
            SystemMessage(content="Produce a 3-5 step plan. The list only."),
            HumanMessage(content=user_input)
        ])
        print(f"Plan: {plan.content[:80]}...")
        
        messages = [
            SystemMessage(content=f"Execute this plan:\n{plan.content}"),
            HumanMessage(content=user_input)
        ]
        for _ in range(8):
            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 "Deliberative timeout"

print(hybrid_agent("How much is 25 * 4?"))
print(hybrid_agent("Research the 3 main AI trends in 2026 and compare them"))

# Expected output:
# Complexity: SIMPLE
# → Mode: REACTIVE
# 25 × 4 = 100
#
# Complexity: COMPLEX
# → Mode: DELIBERATIVE
# Plan: 1. Search for AI trends 2026...
# [Result with research and comparison]

The complexity router is the key component. In production it can use simple heuristics (input length, keywords), an LLM classifier call, or a lightweight fine-tuned model.


Mapping Between Taxonomies

Russell & NorvigPractical TaxonomyMemoryPlansEvaluates options
Simple ReflexReactive
Model-BasedReactive (with state)
Goal-BasedDeliberative
Utility-BasedDeliberative / Hybrid

Modern LLM agents (create_react_agent) are model-based by default — message history as the world model. With planning (Module 5) they become goal-based. With tool selection optimized by cost/quality, they become utility-based.

Most production agents live in the model-based to goal-based zone, with hybrid behavior. It's rare to use an LLM for simple reflex (an if/else does the job) and rare to build a pure utility-based agent (utility evaluation adds complexity).


Comparison

AspectReactiveDeliberativeHybrid
LatencyLow (1-2 iterations)High (planning + execution)Variable
ComplexityLow (~25 lines)High (~80+ lines)Medium (~60 + router)
Ideal tasksQ&A, lookups, 1-2 toolsResearch, reportsGeneral production
Cost (tokens)LowHigh (more LLM calls)Optimized
In LangGraphcreate_react_agentPlan-and-executeRouter + conditional edges
Russell & NorvigSimple/Model-basedGoal-basedUtility-based

When to use each type

  • Reactive: "What's my balance?" → 1 tool call → answer. That's 80% of typical queries.
  • Deliberative: "Analyze Q1 sales, compare with Q4, generate recommendations." Dependent steps.
  • Hybrid: Any production system that receives varied inputs.
  • Simple reflex with an LLM: If an if/else does the job, don't pay for tokens.
  • Deliberative for everything: Planning "how much is 2+2?" is over-engineering.

Where create_react_agent Falls in the Taxonomy

LangGraph's prebuilt agent (create_react_agent) is model-based reactive by default:

  • Model-based: It keeps the message history (world model)
  • Reactive: It decides tool-by-tool with no explicit planning
  • Not goal-based: It doesn't generate a plan before acting
  • Not utility-based: It doesn't evaluate alternatives; it takes the model's first decision
from langgraph.prebuilt import create_react_agent

agent = create_react_agent(model, [search, calculator])
result = agent.invoke({"messages": [("user", "What is MCP in AI agents?")]})
print(result["messages"][-1].content)

# Expected output:
# MCP (Model Context Protocol) is a standard protocol for connecting
# AI models to external tools and data sources...

This isn't a limitation — it's a design decision. For most tasks, reactive is enough. When you need deliberation, you'll use planning patterns (Module 5) or a custom StateGraph (Module 4).


The Reality in Production: Almost Everything Is Hybrid

The practical production solution combines both modes:

  1. Complexity router — Classifies the input as simple or complex
  2. Reactive path — For simple queries: straight to tools
  3. Deliberative path — For complex tasks: plan → execute → review
  4. Feedback loop — If the reactive path fails, escalate to deliberative

This pattern is exactly what you'll build in the evolving project (Research Agent, Modules 4-10).


Connection with the Project

In this module's project (capsule 08): you'll implement a manual ReAct agent that is model-based reactive and compare its behavior on simple vs complex tasks.

In the evolving project (Modules 4-10):

  • Module 4: StateGraph lets you model reactive and deliberative paths as distinct nodes
  • Module 5: Planning and reflection turn the agent into a goal-based deliberative one
  • Module 8: Multi-agent system: a supervisor (goal-based) coordinates workers (reactive)
  • Module 10: The full hybrid pattern with complexity routing in production

The taxonomy is the vocabulary for documenting design decisions: "This agent is hybrid with a complexity router, a reactive path for Q&A and a deliberative path for research."


Troubleshooting

Problem 1: The deliberative agent is too slow for simple tasks

Cause: You're using deliberative for everything — including questions that resolve in a single tool call.

Solution: Implement a complexity router with simple heuristics (input length, keywords like "research", "compare"). It doesn't need to be perfect — even a basic heuristic improves latency significantly.

Problem 2: The reactive agent gets lost on multi-step tasks

Cause: The agent makes local decisions that don't lead to the correct global result.

Solution: Detect that the task failed (timeout, incomplete result) and restart with planning:

result = reactive_agent(user_input)
if result == "Iteration limit reached":
    result = deliberative_agent(user_input)

Problem 3: The generated plan makes no sense

Cause: The planning prompt is generic. The LLM produces vague steps.

Solution: Include the available tools in the planning prompt so the model produces executable steps: "Available tools: web_search(query), calculator(expression). Each step must use a tool with concrete arguments."

Problem 4: The complexity router misclassifies

Cause: The LLM-based router adds latency and can get it wrong.

Solution: Use heuristics first (short question with "?" = SIMPLE, long input = COMPLEX), and the LLM only as a fallback for ambiguous cases.

Problem 5: I don't know which type of agent to build

Solution — quick decision rule:

  • ✅ Does it resolve in 1-2 tool calls? → Reactive
  • ✅ Do the steps depend on prior results? → Deliberative
  • ✅ Does the system receive varied inputs? → Hybrid
  • ✅ Not sure? → Start reactive, escalate if it fails

Exercises

Exercise 1: Classify existing agents (Easy)

Classify each system according to the Russell & Norvig taxonomy AND the practical taxonomy:

  1. A support chatbot that answers questions by consulting a knowledge base
  2. An agent that receives "write a blog post" and generates outline → search → draft → review
  3. A router that sends support queries to the right department based on keywords
  4. An agent that chooses among 3 translation APIs based on cost, speed, and language quality
See solution
  1. Model-based / Reactive — Conversation history (model-based), answers query by query (reactive).
  2. Goal-based / Deliberative — Explicit goal, generates a plan (outline → search → draft → review).
  3. Simple reflex / Reactive — Maps keywords to departments with fixed rules. Doesn't need an LLM.
  4. Utility-based / Hybrid — Evaluates APIs by cost + speed + quality. Maximizes a composite utility.

Explanation: Ask yourself: does it have memory? (model-based), does it plan? (goal-based), does it evaluate alternatives? (utility-based). If none of them → simple reflex.

Exercise 2: Implement a complexity router (Easy)

Implement a function classify_task(user_input: str) -> str that returns "REACTIVE" or "DELIBERATIVE" using heuristics only (no LLM call). It must correctly classify these 4 inputs:

  • "How much is 15 * 23?" → REACTIVE
  • "Research AI trends in education, compare 3 frameworks, and produce a report" → DELIBERATIVE
  • "What time is it?" → REACTIVE
  • "Analyze the pros and cons of microservices vs monoliths for a 5-person startup" → DELIBERATIVE
See solution
def classify_task(user_input: str) -> str:
    """Classifies complexity with heuristics, no LLM."""
    words = user_input.lower().split()
    deliberative_keywords = ["research", "analyze", "compare", "report", "generate", "pros", "cons", "trends"]
    
    action_count = sum(1 for w in words if w in deliberative_keywords)
    if action_count >= 2:
        return "DELIBERATIVE"
    if len(words) > 20:
        return "DELIBERATIVE"
    if len(words) <= 10 and "?" in user_input:
        return "REACTIVE"
    return "REACTIVE"

test_cases = [
    ("How much is 15 * 23?", "REACTIVE"),
    ("Research AI trends, compare 3 frameworks, generate report", "DELIBERATIVE"),
    ("What time is it?", "REACTIVE"),
    ("Analyze the pros and cons of microservices vs monoliths", "DELIBERATIVE"),
]
for text, expected in test_cases:
    result = classify_task(text)
    print(f"{'✅' if result == expected else '❌'} '{text[:45]}...' → {result}")

# Expected output:
# ✅ 'How much is 15 * 23?...' → REACTIVE
# ✅ 'Research AI trends, compare 3 frameworks, gen...' → DELIBERATIVE
# ✅ 'What time is it?...' → REACTIVE
# ✅ 'Analyze the pros and cons of microservices vs...' → DELIBERATIVE

Explanation: Heuristics are imperfect but instant (0ms vs ~500ms for an LLM call). In production you combine heuristics with an LLM classifier as a fallback: the heuristics resolve 80% of cases, the LLM only the ambiguous 20%.

Exercise 3: Convert reactive to deliberative (Medium)

Given the reactive_agent from this capsule, convert it into one that first generates a 3-step plan and then executes. Test it with: "What's the weather in Madrid and in Barcelona, and which city has the better temperature?"

See solution
def deliberative_weather_agent(user_input: str) -> str:
    # PHASE 1: Plan
    plan = model.invoke([
        SystemMessage(content="Produce a 3-step plan. Numbered list only."),
        HumanMessage(content=user_input)
    ])
    print(f"Plan:\n{plan.content}\n")
    
    # PHASE 2: Execute with the plan as context
    messages = [
        SystemMessage(content=f"Follow this plan:\n{plan.content}\nUse get_weather for real data."),
        HumanMessage(content=user_input)
    ]
    for _ in range(6):
        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 "Timeout"

print(deliberative_weather_agent("Weather in Madrid and Barcelona, which has the better temperature?"))

# Expected output:
# Plan:
# 1. Check the current weather in Madrid
# 2. Check the current weather in Barcelona
# 3. Compare temperatures and determine which is better
#
# Madrid has 22°C (sunny) and Barcelona has 20°C (cloudy).
# Madrid has the better temperature at 22°C with clear skies.

Explanation: The deliberative agent produces a plan BEFORE executing. The plan gives it structure: it knows it needs two lookups and then a comparison. The reactive agent would do the same by the LLM's "instinct," but for more complex tasks an explicit plan keeps the agent from skipping steps.

Exercise 4: Design a utility function (Medium)

Implement evaluate_tool_utility(query, tool_metadata) that assigns a 0-100 score to each tool. Use heuristics (no LLM). Metadata: name, cost_per_call, avg_latency_ms, accuracy_score.

See solution
def evaluate_tool_utility(query: str, tool_metadata: dict) -> float:
    """Evaluates a tool's utility for a query (0-100)."""
    relevance_map = {
        "calculator": ["calculate", "how much", "sum", "multiply"],
        "web_search": ["search", "research", "trends", "current"],
        "get_weather": ["weather", "temperature", "rain", "forecast"],
    }
    keywords = relevance_map.get(tool_metadata["name"], [])
    keyword_hits = sum(1 for kw in keywords if kw in query.lower())
    relevance = min(keyword_hits / max(len(keywords), 1) * 40, 40)
    cost = (1 - min(tool_metadata["cost_per_call"] / 0.05, 1)) * 20
    latency = (1 - min(tool_metadata["avg_latency_ms"] / 5000, 1)) * 20
    accuracy = tool_metadata["accuracy_score"] * 20
    return round(relevance + cost + latency + accuracy, 1)

tools_metadata = [
    {"name": "calculator", "cost_per_call": 0.0, "avg_latency_ms": 10, "accuracy_score": 1.0},
    {"name": "web_search", "cost_per_call": 0.01, "avg_latency_ms": 2000, "accuracy_score": 0.8},
    {"name": "get_weather", "cost_per_call": 0.005, "avg_latency_ms": 500, "accuracy_score": 0.95},
]

for t in tools_metadata:
    print(f"  {t['name']}: {evaluate_tool_utility('What is the temperature in Madrid?', t)}/100")

# Expected output:
#   calculator: 40.0/100      (irrelevant but cheap/fast)
#   web_search: 38.0/100      (somewhat relevant but slow)
#   get_weather: 71.8/100     (highly relevant, fast, accurate)

Explanation: The utility function combines relevance, cost, latency, and accuracy with configurable weights. In production, the weights are tuned to your priorities: if latency matters more (interactive UX), raise the latency weight; if accuracy is critical (medical, legal), raise accuracy.

Exercise 5: Implement a reactive → deliberative fallback (Hard)

Implement an agent that tries to solve in reactive mode (max 2 iterations). If it fails (hits the limit without an answer), it automatically escalates to deliberative mode.

See solution
def reactive_attempt(user_input: str, max_iter: int = 2) -> tuple[str, bool]:
    """Tries to solve reactively. Returns (result, success)."""
    messages = [HumanMessage(content=user_input)]
    for _ in range(max_iter):
        response = model_with_tools.invoke(messages)
        messages.append(response)
        if not response.tool_calls:
            return response.content, True
        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 "Not completed", False

def deliberative_attempt(user_input: str) -> str:
    """Solves with explicit planning."""
    plan = model.invoke([
        SystemMessage(content="Produce a 3-5 step plan. The list only."),
        HumanMessage(content=user_input)
    ])
    messages = [
        SystemMessage(content=f"Execute this plan:\n{plan.content}"),
        HumanMessage(content=user_input)
    ]
    for _ in range(8):
        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 "Deliberative timeout"

def fallback_agent(user_input: str) -> str:
    """Automatic escalation: reactive → deliberative."""
    result, success = reactive_attempt(user_input, max_iter=2)
    if success:
        return result
    print("Reactive failed → escalating to deliberative...")
    return deliberative_attempt(user_input)

# Simple task: reactive is enough
print(fallback_agent("Search for information about quantum computing"))

# Complex task: reactive fails, deliberative solves it
print(fallback_agent("Search 3 AI topics, summarize each one, and produce a comparative synthesis"))

# Expected output:
# [Information about quantum computing...] (reactive)
# Reactive failed → escalating to deliberative...
# [Comparative synthesis of the 3 topics] (deliberative)

Explanation: The fallback pattern is the most pragmatic way to implement a hybrid agent. You try the fast path (reactive) and only pay the extra cost (deliberative) when you have to. The max_iter=2 is deliberately low: if it wasn't resolved in 2 iterations, it probably needs planning.

Exercise 6: Document an agent with the taxonomy (Hard)

A technical support agent: it keeps history, has 8 tools, answers direct questions without planning, but for complex troubleshooting it generates a step-by-step diagnosis. Document it using both taxonomies.

See solution
# Technical Support Agent — Architecture

## Russell & Norvig Taxonomy
- **Primary:** Model-Based (history as the world model)
- **Secondary:** Goal-Based (troubleshooting generates a diagnostic plan)

## Practical Taxonomy
- **Type:** Hybrid (reactive for Q&A, deliberative for troubleshooting)
- **Router:** Keywords + input length
  - Input < 15 words + "?" → REACTIVE
  - Keywords ["error", "failure", "not working"] → DELIBERATIVE
- **Max iterations:** 3 (reactive), 8 (deliberative)
- **Fallback:** reactive timeout → deliberative → human escalation

## Design decision
Hybrid because 70% of queries are direct questions (1 tool call).
Only 30% need multi-step diagnosis. Deliberative for everything
would add ~2s of unnecessary latency to that 70%.

Explanation: Documenting with the taxonomy forces design clarity. Including the reason for the decision (the 70/30 split) keeps someone from changing the architecture without understanding the original trade-off.


Summary

In this capsule you learned:

  • The Russell & Norvig taxonomy classifies agents into 4 levels: simple reflex, model-based, goal-based, utility-based
  • The practical taxonomy splits agents into 3 types: reactive, deliberative, hybrid
  • create_react_agent is model-based reactive by default — it keeps history but doesn't plan
  • A deliberative agent generates a plan before executing — better for complex tasks, slower
  • Hybrid agents are the production standard: a complexity router chooses between the reactive and deliberative paths
  • A utility function lets you choose among alternatives while optimizing cost, latency, and quality
  • Documenting the agent type in your architecture improves the system's maintainability

Next capsule: Agents vs Chains vs Workflows — you'll learn a decision framework for choosing when to use an autonomous agent, when a deterministic chain, and when an orchestrated workflow. It's the most important architectural decision in AI Engineering.


Additional Resources

  1. Russell & Norvig: Intelligent Agents (AIMA Ch. 2) — The foundational chapter: the 4-level agent taxonomy
  2. Building Effective Agents (Anthropic) — A practical perspective on design and when to use agents
  3. LangGraph create_react_agent — The API for the prebuilt model-based reactive agent
  4. Plan-and-Execute Agents (LangChain Blog) — The deliberative pattern in LangGraph
  5. ReAct Paper — The paper that formalized the ReAct loop
  6. Cognitive Architectures for Language Agents (CoALA) — A framework for classifying agents' cognitive architectures
  7. LangGraph Agent Architectures — Agent architecture concepts
  8. Emerging AI Agent Architectures Survey — A survey of emerging architectures