Module 1: Anatomy of an AI Agent
8. Project: A Basic ReAct Agent from Scratch
Project overview
Throughout this module you learned what an AI agent is, how the perceive-reason-act cognitive architecture works, what types of agents exist, how they differ from chains and workflows, which frameworks are available, and when an agent is the right solution. Now you'll pull all that knowledge into a concrete project: building a working ReAct agent two different ways — first by hand with the OpenAI API, and then with LangGraph's create_react_agent.
Why two implementations? Because the difference between "knowing how to use a framework" and "understanding what an agent does" lives exactly there. When you implement the ReAct loop manually — prompt, parse, execute, append, repeat — you understand every decision the framework makes for you. When you then reimplement it with create_react_agent and see the same result achieved with ~80% less code, you understand what the framework abstracts and why it exists. Both understandings matter equally.
This project has three real tools: get_weather (a city's weather), calculator (math expressions), and get_date (current date and time). The agent has to solve queries that require one, two, or three tools, and stop automatically once it has enough information — no infinite loops, no incomplete answers.
It's a standalone mini-project. It isn't part of the evolving project (the AI Research Agent) that starts in Module 4. But the loop you implement here is exactly the building block you'll use when you design state machines, planning, and multi-agent systems in later modules.
Estimated time: 30-45 minutes.
Project Goal
Build a working ReAct agent that solves multi-tool queries, implemented two ways:
- Manual: Using only the OpenAI SDK, no framework. You control the loop, the tool_calls parsing, the tool execution, and the stop conditions.
- Framework: With LangGraph's
create_react_agent. Same result, a fraction of the code.
By the end you'll be able to:
- Implement the full perceive-reason-act loop at the code level
- Define tools with JSON schemas (manual) and with
@tool(framework) - Control stop conditions:
max_iterationsand task-complete detection - Articulate precisely what
create_react_agentabstracts internally - Decide when the manual loop adds value vs when the framework is enough
Technical Specifications
Stack
| Technology | Version | Use |
|---|---|---|
| Python | 3.11+ | Runtime |
| openai | 1.0+ | Direct API (Part 1) |
| langchain | v1.2+ | init_chat_model, @tool |
| langgraph | v1.0+ | create_react_agent |
| python-dotenv | any | Environment variables |
Setup
pip install openai langchain langchain-openai langgraph python-dotenv
Create a .env file in the project directory:
OPENAI_API_KEY=sk-proj-your-api-key-here
File structure
react-agent-project/
├── .env # API key
├── react_manual.py # Part 1: manual implementation
├── react_framework.py # Part 2: implementation with create_react_agent
└── comparison.py # Part 3: comparative analysis
Part 1: Implementing the ReAct Loop by Hand
This is the core of the project. You're going to build the complete ReAct loop using only the OpenAI SDK — no LangChain, no LangGraph, no abstractions. Every line of code maps to a step of the perceive-reason-act cycle you studied in capsule 03.
Step 1.1: Setup and configuration
# react_manual.py
from dotenv import load_dotenv
load_dotenv()
from openai import OpenAI
import json
from datetime import datetime
client = OpenAI()
MODEL = "gpt-4.1-mini"
Step 1.2: Define the tools
Each tool needs two things: a JSON schema that tells the model what it can do and which arguments it expects, and a Python function that performs the real action.
The schemas follow the OpenAI Function Calling format — the same one you saw in capsule 03 when you studied how the LLM generates tool_calls.
tools_spec = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a city. Use this tool whenever the user asks about weather, temperature or meteorological conditions anywhere.",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "City name, e.g.: 'Madrid', 'Mexico City', 'Tokyo'"
}
},
"required": ["city"]
}
}
},
{
"type": "function",
"function": {
"name": "calculator",
"description": "Evaluate math expressions. Supports basic operations (+, -, *, /), powers (**), and parentheses. Use this tool for any numeric calculation.",
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "Math expression to evaluate, e.g.: '15 * 23', '(100 + 50) / 3', '2 ** 10'"
}
},
"required": ["expression"]
}
}
},
{
"type": "function",
"function": {
"name": "get_date",
"description": "Get the current date and time. Use this tool when the user asks what day it is, the current time, or any temporal information.",
"parameters": {
"type": "object",
"properties": {},
"required": []
}
}
}
]
Three design decisions matter here:
- Detailed descriptions. The model decides which tool to use based on the description. A vague description produces incorrect tool selection.
- Explicit types.
"type": "string"for each argument, with examples in the description. This reduces formatting errors in the arguments the model generates. - get_date takes no parameters. The schema has
"properties": {}— the tool needs no arguments. The model calls it without sending anything.
Step 1.3: Implement the execution functions
def execute_tool(name: str, args: dict) -> str:
"""Run a tool by name and return the result as a string."""
if name == "get_weather":
city = args.get("city", "unknown")
weather_data = {
"Madrid": "22°C, sunny, 45% humidity",
"Mexico City": "18°C, partly cloudy, 62% humidity",
"Tokyo": "26°C, rainy, 78% humidity",
"Buenos Aires": "15°C, windy, 55% humidity",
"New York": "12°C, cloudy, 68% humidity",
}
weather = weather_data.get(city, f"19°C, clear, 50% humidity (simulated data for {city})")
return f"Weather in {city}: {weather}"
elif name == "calculator":
expression = args.get("expression", "0")
try:
allowed_chars = set("0123456789+-*/.() ")
if not all(c in allowed_chars for c in expression):
return f"Error: expression contains disallowed characters: {expression}"
result = eval(expression)
return f"{expression} = {result}"
except Exception as e:
return f"Error computing '{expression}': {e}"
elif name == "get_date":
now = datetime.now()
return f"Current date: {now.strftime('%A, %B %d, %Y, %H:%M:%S')}"
return f"Error: tool '{name}' not recognized"
Three points about this implementation:
- get_weather uses simulated data. In a real project you'd connect to an API like OpenWeatherMap. Here the simulation is intentional — the focus is the ReAct loop, not external API integration (you cover that in Module 2).
- calculator validates characters.
eval()is dangerous in production because it can execute arbitrary code. Character validation is a minimal safety net. In Module 2 you'll seeast.literal_evaland safe parsers. - Everything returns a string. OpenAI expects a tool's result to be a string. If your tool produces a dict or a number, convert it to a string before returning it.
Step 1.4: The ReAct loop
This is the heart of the project. Every line maps to a step of the cycle you studied:
def react_loop(user_query: str, max_iterations: int = 5, verbose: bool = True) -> str:
"""Run the full ReAct loop: perceive → reason → act → observe → repeat.
Args:
user_query: The user's question.
max_iterations: Max cycles before forcing an answer.
verbose: If True, prints every step of the loop.
Returns:
The agent's final answer.
"""
system_prompt = (
"You are a helpful assistant with access to tools. "
"Think step by step. Use the available tools whenever you need "
"information you don't have (weather, calculations, date). "
"Once you have all the information you need, answer the user directly."
)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_query}
]
if verbose:
print(f"\n{'='*60}")
print(f" Query: {user_query}")
print(f"{'='*60}")
for iteration in range(max_iterations):
# --- PERCEIVE + REASON ---
# The model receives every accumulated message and decides:
# option A: call tools (generates tool_calls)
# option B: answer the user (generates content with no tool_calls)
response = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=tools_spec,
tool_choice="auto"
)
assistant_msg = response.choices[0].message
# Build the assistant message for the history
msg_to_append = {
"role": "assistant",
"content": assistant_msg.content or ""
}
if assistant_msg.tool_calls:
msg_to_append["tool_calls"] = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments
}
}
for tc in assistant_msg.tool_calls
]
messages.append(msg_to_append)
# --- STOP CONDITION: no tool_calls = task complete ---
if not assistant_msg.tool_calls:
if verbose:
print(f"\n [Iteration {iteration + 1}] Final answer (no tool calls)")
print(f" Total iterations: {iteration + 1}")
return assistant_msg.content or "No answer."
# --- ACT ---
if verbose:
print(f"\n [Iteration {iteration + 1}] Tool calls:")
for tc in assistant_msg.tool_calls:
func_name = tc.function.name
func_args = json.loads(tc.function.arguments)
result = execute_tool(func_name, func_args)
if verbose:
print(f" → {func_name}({func_args}) = {result}")
# --- OBSERVE ---
# Add the result as a ToolMessage so the model can see it
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": result
})
# Stop condition: max_iterations reached
if verbose:
print(f"\n ⚠ Max iterations ({max_iterations}) reached")
return "Reached the maximum number of iterations without completing the task."
Let's look at the key decisions in this loop:
A minimal system prompt. It tells the model to think step by step and use tools when it needs information. You don't need a 500-word prompt — the model's tool_calls format already enforces the ReAct pattern.
tool_choice="auto". The model freely decides whether to use tools or answer directly. If you switch to "required", the model will always call at least one tool — useful in certain cases, but it breaks the natural stop condition.
A dual stop condition. The loop ends for two reasons:
- The model generates no
tool_calls→ we interpret that as having enough information to answer. max_iterationsis reached → a safety net against infinite loops.
tool_call_id is mandatory. Every ToolMessage must include the tool_call_id matching the model's tool_call. Without that ID, the OpenAI API rejects the request. It's the mechanism that lets the model correlate "I asked for X" with "the result of X is Y".
Step 1.5: Test the manual agent
if __name__ == "__main__":
# Case 1: One tool
print("\n" + "="*60)
print("CASE 1: One tool (calculator)")
print("="*60)
r1 = react_loop("How much is 1547 * 23 + 890?")
print(f"\n Answer: {r1}")
# Case 2: Two tools
print("\n" + "="*60)
print("CASE 2: Two tools (weather + calculator)")
print("="*60)
r2 = react_loop("What's the weather in Madrid and how much is 15 * 23?")
print(f"\n Answer: {r2}")
# Case 3: Three tools
print("\n" + "="*60)
print("CASE 3: Three tools (date + weather + calculator)")
print("="*60)
r3 = react_loop("What day is it today, what's the weather in Tokyo, and how much is 2 ** 16?")
print(f"\n Answer: {r3}")
# Case 4: No tools (direct answer)
print("\n" + "="*60)
print("CASE 4: No tools (the model's own knowledge)")
print("="*60)
r4 = react_loop("What is the capital of France?")
print(f"\n Answer: {r4}")
Expected output
============================================================
Query: How much is 1547 * 23 + 890?
============================================================
[Iteration 1] Tool calls:
→ calculator({'expression': '1547 * 23 + 890'}) = 1547 * 23 + 890 = 36471
[Iteration 2] Final answer (no tool calls)
Total iterations: 2
Answer: The result of 1547 × 23 + 890 is **36,471**.
============================================================
Query: What's the weather in Madrid and how much is 15 * 23?
============================================================
[Iteration 1] Tool calls:
→ get_weather({'city': 'Madrid'}) = Weather in Madrid: 22°C, sunny, 45% humidity
→ calculator({'expression': '15 * 23'}) = 15 * 23 = 345
[Iteration 2] Final answer (no tool calls)
Total iterations: 2
Answer: The weather in Madrid is 22°C and sunny with 45% humidity. And 15 × 23 = **345**.
============================================================
Query: What day is it today, what's the weather in Tokyo, and how much is 2 ** 16?
============================================================
[Iteration 1] Tool calls:
→ get_date({}) = Current date: Saturday, March 08, 2026, 14:23:15
→ get_weather({'city': 'Tokyo'}) = Weather in Tokyo: 26°C, rainy, 78% humidity
→ calculator({'expression': '2 ** 16'}) = 2 ** 16 = 65536
[Iteration 2] Final answer (no tool calls)
Total iterations: 2
Answer: Today is Saturday, March 8, 2026. In Tokyo the weather is 26°C,
rainy with 78% humidity. And 2^16 = **65,536**.
============================================================
Query: What is the capital of France?
============================================================
[Iteration 1] Final answer (no tool calls)
Total iterations: 1
Answer: The capital of France is **Paris**.
Notice the patterns:
- Cases 1-3: The model calls tools on iteration 1, receives results, and answers on iteration 2. That's 2 iterations = 2 LLM calls.
- Cases 2-3: The model generates multiple
tool_callsin parallel within a single iteration. It doesn't need one iteration per tool. - Case 4: The model answers directly without calling tools. Just 1 iteration = 1 LLM call. The loop doesn't force tool use.
Part 2: Reimplementing with create_react_agent
Now you'll implement exactly the same functionality with LangGraph's create_react_agent. The contrast is dramatic.
Step 2.1: The complete code
# react_framework.py
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langgraph.prebuilt import create_react_agent
from datetime import datetime
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
weather_data = {
"Madrid": "22°C, sunny, 45% humidity",
"Mexico City": "18°C, partly cloudy, 62% humidity",
"Tokyo": "26°C, rainy, 78% humidity",
"Buenos Aires": "15°C, windy, 55% humidity",
"New York": "12°C, cloudy, 68% humidity",
}
weather = weather_data.get(city, f"19°C, clear, 50% humidity (simulated data for {city})")
return f"Weather in {city}: {weather}"
@tool
def calculator(expression: str) -> str:
"""Evaluate math expressions. Supports +, -, *, /, ** and parentheses."""
try:
allowed_chars = set("0123456789+-*/.() ")
if not all(c in allowed_chars for c in expression):
return f"Error: disallowed characters in '{expression}'"
return f"{expression} = {eval(expression)}"
except Exception as e:
return f"Error: {e}"
@tool
def get_date() -> str:
"""Get the current date and time."""
now = datetime.now()
return f"Current date: {now.strftime('%A, %B %d, %Y, %H:%M:%S')}"
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_react_agent(model, [get_weather, calculator, get_date])
if __name__ == "__main__":
queries = [
"How much is 1547 * 23 + 890?",
"What's the weather in Madrid and how much is 15 * 23?",
"What day is it today, what's the weather in Tokyo, and how much is 2 ** 16?",
"What is the capital of France?",
]
for query in queries:
print(f"\n{'='*60}")
print(f" Query: {query}")
print(f"{'='*60}")
result = agent.invoke({"messages": [("user", query)]})
print(f" Answer: {result['messages'][-1].content}")
Expected output
============================================================
Query: How much is 1547 * 23 + 890?
============================================================
Answer: The result of 1547 × 23 + 890 is **36,471**.
============================================================
Query: What's the weather in Madrid and how much is 15 * 23?
============================================================
Answer: The weather in Madrid is 22°C and sunny with 45% humidity. And 15 × 23 = **345**.
============================================================
Query: What day is it today, what's the weather in Tokyo, and how much is 2 ** 16?
============================================================
Answer: Today is Saturday, March 8, 2026. In Tokyo it's 26°C, rainy with
78% humidity. And 2^16 = **65,536**.
============================================================
Query: What is the capital of France?
============================================================
Answer: The capital of France is **Paris**.
The results are the same as in the manual implementation. Same queries, same tools, same answers.
Step 2.2: Inspect the internal flow
create_react_agent returns a compiled LangGraph you can inspect. To see what's happening internally:
result = agent.invoke(
{"messages": [("user", "Weather in Madrid and how much is 15 * 23?")]},
)
for msg in result["messages"]:
msg_type = type(msg).__name__
if hasattr(msg, "tool_calls") and msg.tool_calls:
tools_used = [tc["name"] for tc in msg.tool_calls]
print(f" {msg_type}: [tool_calls: {tools_used}]")
elif hasattr(msg, "content") and msg.content:
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
print(f" {msg_type}: {preview}")
Output:
HumanMessage: Weather in Madrid and how much is 15 * 23?
AIMessage: [tool_calls: ['get_weather', 'calculator']]
ToolMessage: Weather in Madrid: 22°C, sunny, 45% humidity
ToolMessage: 15 * 23 = 345
AIMessage: The weather in Madrid is 22°C and sunny with 45% humidity. And 15 × 2...
It's the same flow: HumanMessage → AIMessage with tool_calls → ToolMessages → AIMessage with the final answer. The sequence is identical to what you implemented by hand. The difference is that create_react_agent builds a StateGraph with two nodes ("call model" and "execute tools") connected by a conditional edge, and manages the whole cycle automatically.
Part 3: Comparative Analysis
This is the part that turns the project from a "coding exercise" into "engineering understanding." You'll compare the two implementations in detail and articulate what the framework abstracts.
Quantitative comparison
| Metric | Manual (react_manual.py) | Framework (react_framework.py) |
|---|---|---|
| Total lines | ~90 | ~45 |
| Lines for the loop | ~40 | 1 (create_react_agent(...)) |
| Tool definitions | ~50 (JSON schemas) | ~25 (@tool + docstrings) |
| tool_calls parsing | Manual (json.loads, iterate) | Automatic |
| Message management | Manual (build dicts) | Automatic (MessagesState) |
| Stop conditions | Manual (check tool_calls, max_iterations) | Built-in |
| Error handling in tools | Manual | Partially automatic |
| Implementation time | ~30 min | ~5 min |
What create_react_agent abstracts
Internally, create_react_agent does exactly what you implemented in Part 1. Specifically:
1. The cyclic loop.
Your for iteration in range(max_iterations) becomes a StateGraph with conditional edges cycling between the "agent" node (call the model) and the "tools" node (run the tools). The exit condition is the same: if the model generates no tool_calls, the graph ends.
# What you implemented:
for iteration in range(max_iterations):
response = client.chat.completions.create(...)
if not response.tool_calls:
return response.content
for tc in response.tool_calls:
execute_tool(tc)
# What create_react_agent builds internally:
# A StateGraph with "agent" and "tools" nodes
# Conditional edge: if there are tool_calls → "tools", otherwise → END
# The graph cycles until the model stops asking for tools
2. Message format.
You build dicts by hand ({"role": "assistant", "content": ...}). The framework uses typed objects (AIMessage, ToolMessage, HumanMessage) that handle serialization internally. This eliminates formatting errors like forgetting tool_call_id or the tool_calls structure.
3. tool_calls parsing.
You do json.loads(tc.function.arguments) for each tool call. The framework parses automatically and maps arguments to the Python function's parameters.
4. Execution routing.
You need the if name == "get_weather" dispatch. The framework keeps a registry of tools by name and automatically runs the right function.
5. Tool schemas. You write 50 lines of JSON schema. The framework generates schemas from the function signature + docstring + type hints. Less code, fewer errors.
6. Stop conditions.
You implement max_iterations and the not tool_calls check. The framework has recursion_limit (equivalent to max_iterations) and the same "no tool_calls = finish" logic.
When to use each approach
| Scenario | Manual | Framework |
|---|---|---|
| Learning and understanding | ✅ | ❌ |
| Debugging the ReAct loop | ✅ | ❌ |
| Production with standard agents | ❌ | ✅ |
| Complex custom stop conditions | ✅ | Partial |
| Granular per-iteration prompt control | ✅ | ❌ |
| Fast prototyping | ❌ | ✅ |
| Integration with a custom StateGraph | ❌ | ✅ (Module 4) |
Rule of thumb: Use the manual loop when you need to understand, debug, or customize the cycle at a granular level. Use create_react_agent when the standard loop is enough. And when you need total control with the framework's advantages, use StateGraph with custom nodes (Module 4).
What create_react_agent does NOT abstract
The framework manages the loop, but some things remain your responsibility:
- The quality of your tool descriptions. If your tool's description is vague, the model will pick it badly. Neither the manual loop nor the framework fixes that.
- The internal logic of each tool. The framework runs your function, but if your function has bugs, the results will be wrong.
- The system prompt.
create_react_agentaccepts a system prompt via thepromptparameter, but designing a good prompt is still your job. - Experience design. How many tools, which ones, what they do, how they interact — those design decisions are yours.
Required Features
Your project must include all of these. If one is missing, it isn't complete.
Part 1 (Manual):
- Three working tools:
get_weather,calculator,get_date - Complete JSON schemas with clear descriptions
- A ReAct loop with perceive → reason → act → observe
- Stop condition 1: no
tool_calls= task complete - Stop condition 2:
max_iterationsas a safety net - Support for parallel tool calls (multiple tools in one iteration)
- A verbose mode showing every step of the loop
- Error handling in calculator (invalid expressions)
Part 2 (Framework):
- The same three tools with
@tool - LangGraph's
create_react_agent(not the one fromlangchain.agents) - The same queries produce equivalent answers
- Inspection of the internal message flow
Part 3 (Analysis):
- A comparison table of lines of code
- An articulation of what
create_react_agentabstracts - A reasoned decision on when to use each approach
Success Criteria
To consider this project successfully finished, check these 4 criteria:
1. The manual loop works with all 3 tools
The manual agent solves queries requiring one, two, or three tools. Not just the simple one-tool case — you need multi-tool queries like "What day is it today, what's the weather in Madrid, and how much is 42 * 73?".
2. The agent stops when it completes the task
The agent doesn't get stuck in an infinite loop. When it has all the information, it answers without calling more tools. When it needs no tools (like "What is the capital of France?"), it answers on the first iteration.
3. The framework version produces the same result with ~80% less code
The same set of queries produces equivalent answers with create_react_agent. The Part 2 code is significantly shorter than Part 1's.
4. You can articulate what create_react_agent abstracts
"It's less code" isn't enough. You should be able to explain the 6 abstraction points: the cyclic loop, message format, tool_calls parsing, execution routing, tool schemas, and stop conditions. If someone asks you "what does create_react_agent do internally?", your answer should be precise and grounded in what you implemented.
Completion Checklist
Check every point before considering the project done:
Setup:
-
.envwithOPENAI_API_KEYconfigured - Dependencies installed (
openai,langchain,langgraph,python-dotenv) - All three files created (
react_manual.py,react_framework.py,comparison.py)
Part 1 — Manual Loop:
-
tools_specdefines 3 tools with complete JSON schemas -
execute_tool()runs all 3 tools correctly -
react_loop()implements the full perceive-reason-act cycle - The agent solves queries with 1 tool
- The agent solves queries with 2 simultaneous tools
- The agent solves queries with 3 simultaneous tools
- The agent answers directly when it needs no tools
-
max_iterationsprevents infinite loops -
verbose=Trueshows every iteration and tool call - Calculator handles invalid expressions without crashing
Part 2 — Framework:
- Tools defined with
@tooland docstrings - Agent created with
create_react_agentfromlanggraph.prebuilt - The 4 test queries produce answers equivalent to Part 1
- Internal message inspection shows the complete flow
Part 3 — Analysis:
- A comparison table with quantitative metrics
- An explanation of the 6 abstraction points
- A recommendation of when to use each approach
Common Errors
Error 1: Forgetting tool_call_id in the ToolMessage
Symptom: The OpenAI API returns an error like "Missing tool_call_id for tool message".
Cause: Every ToolMessage must reference the specific tool_call that produced it. Without the ID, the model can't correlate which result belongs to which request.
Fix:
# ❌ WRONG: no tool_call_id
messages.append({"role": "tool", "content": result})
# ✅ RIGHT: with the tool_call_id of the matching tool_call
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": result
})
Error 2: tool_calls gets serialized incorrectly when appending
Symptom: Error "Invalid type for 'messages[N].tool_calls'" when sending messages to the API.
Cause: The response's tool_calls object is a Pydantic object, not a dict. If you append it directly without converting it, the next request fails.
Fix:
# ❌ WRONG: appending the response object directly without the right structure
messages.append({"role": "assistant", "tool_calls": assistant_msg.tool_calls})
# ✅ RIGHT: build the dict with the correct structure
msg_to_append = {
"role": "assistant",
"content": assistant_msg.content or ""
}
if assistant_msg.tool_calls:
msg_to_append["tool_calls"] = [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments
}
}
for tc in assistant_msg.tool_calls
]
messages.append(msg_to_append)
Error 3: An infinite loop because the model always asks for tools
Symptom: The agent never answers directly. Every iteration generates tool_calls, even when it already has all the information.
Cause: Usually a system prompt that forces the model to "always use tools", or a tool_choice="required" that mandates tool calls.
Fix:
# ❌ WRONG: tool_choice="required" forces tool calls on every iteration
response = client.chat.completions.create(
model=MODEL, messages=messages, tools=tools_spec,
tool_choice="required"
)
# ✅ RIGHT: tool_choice="auto" lets the model answer without tools
response = client.chat.completions.create(
model=MODEL, messages=messages, tools=tools_spec,
tool_choice="auto"
)
Also make sure your system prompt includes something like "once you have all the information you need, answer the user directly".
Error 4: eval() executes malicious code in calculator
Symptom: No visible error, but eval("__import__('os').system('rm -rf /')") would run a system command.
Cause: eval() evaluates any Python expression, not just math.
Fix: Validate the expression's characters before evaluating it. For production, use a safe parser:
import ast
def safe_calculate(expression: str) -> str:
try:
tree = ast.parse(expression, mode='eval')
for node in ast.walk(tree):
if not isinstance(node, (
ast.Expression, ast.BinOp, ast.UnaryOp, ast.Constant,
ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Pow,
ast.USub, ast.UAdd
)):
return f"Error: operation not allowed in '{expression}'"
return str(eval(compile(tree, '<expr>', 'eval')))
except Exception as e:
return f"Error: {e}"
Error 5: Importing create_react_agent from the wrong place
Symptom: ImportError or unexpected behavior (the agent requires an AgentExecutor wrapper).
Cause: There are two create_react_agent functions in the ecosystem:
langchain.agents.create_react_agent— the legacy version that needsAgentExecutorlanggraph.prebuilt.create_react_agent— the modern version that works directly
Fix:
# ❌ LEGACY: requires AgentExecutor, more code, less flexible
from langchain.agents import create_react_agent, AgentExecutor
# ✅ MODERN: works directly, returns a CompiledGraph
from langgraph.prebuilt import create_react_agent
This guide always uses the LangGraph version.
Error 6: get_date with unexpected parameters
Symptom: The model sends {"timezone": "UTC"} to get_date, but the function accepts no arguments.
Cause: If the description isn't clear, the model can invent parameters.
Fix: Make sure the schema has "properties": {} and "required": [] in the manual case, and that the function has no parameters in the @tool case.
Error 7: Not verifying that the result is a string
Symptom: An error when sending a tool's result as a ToolMessage. The API expects a string but receives an int or a dict.
Fix: Always convert the result to a string before returning it:
# ❌ WRONG: returns an int
return eval(expression)
# ✅ RIGHT: returns a string
return str(eval(expression))
Project Resources
- OpenAI Function Calling Guide — Official documentation for the tools and tool_calls format you use in Part 1
- LangGraph create_react_agent Reference — API reference for the prebuilt agent, its parameters and configuration
- ReAct Paper: Synergizing Reasoning and Acting — The foundational paper that formalized the pattern you're implementing
- LangGraph Concepts: Agent Architectures — An explanation of how LangGraph implements the ReAct loop internally as a StateGraph
- OpenAI Python SDK — The OpenAI SDK repository you use in the manual implementation
- LangChain @tool decorator — How to create tools with
@tool, automatic schemas, and best practices
Connection to the Next Module
In this project you implemented the ReAct loop with 3 simulated tools. The tools work, but their capabilities are limited: the weather is a hardcoded dict, the calculator uses eval, and get_date is trivial.
In Module 2 (Tool Use Fundamentals) you'll turn those toy tools into professional ones:
- Advanced
@tool: Complex schemas with Pydantic, type hints that guide the model, InjectedToolArg for context - Tool execution loop: The same loop you implemented here, but with error handling, retries, and graceful degradation
- Real tools: Web search with Tavily, external APIs, file readers — not simulated data
- Input validation: Pydantic validates the arguments before running the tool, not after
Module 2's mini-project is an agent with 5 real external tools. Everything you learned about the ReAct loop here applies directly — the difference is that the tools will be production-grade.
And in Module 4, when you build the Research Agent's state machine with StateGraph, you'll recognize every component: the "agent" node is your response = client.chat.completions.create(...), the "tools" node is your execute_tool(), and the conditional edge is your if not tool_calls: return. The same structure, but with total control over the flow.