Build a Research Agent in Python
Build a ReAct agent that searches the web and writes research reports.
Introduction
30 Second Summary
When you need to research a complex topic, you open dozens of tabs, skim articles, compare sources, and piece together an answer. It takes time, and you still might miss something important.
In this project, you will build a research agent from scratch in Python. Your agent takes a question, searches the web, reads relevant pages, and produces a structured research summary on its own.
What You'll Build
A terminal-based Python research agent that uses the ReAct (Reason + Act) pattern to autonomously search the web, extract content from pages, and compile structured Markdown research reports.
By the end of this project, you'll have:
- A working ReAct agent loop that reasons about what information it needs and takes actions to find it.
- A modular tool system with web search and page extraction that you can extend with new tools in minutes.
- A complete agent pipeline connecting an LLM, tools, and conversation memory into a single working system.
- Secret Mission: extend your agent with a file-saving tool to export research reports as Markdown files.
Are there any prerequisites?
You should be comfortable with basic Python (variables, functions, loops) and have a general idea of what LLMs are. You will also need a free Hugging Face account with an API token.
Before We Start
To build a research agent, you need an environment where Python can talk to the web and call an LLM. That means installing a few packages and connecting to Hugging Face Inference Providers so your agent has a brain.
In this step you will create a project folder, install your dependencies, generate a Hugging Face API token, and set up the files your agent will live in.
In this step, get ready to:
- Install the project dependencies in a virtual environment.
- Configure Hugging Face authentication with a fine-grained token.
- Create the project file structure.
Install dependencies
Your agent needs two key libraries. The huggingface-hub package connects to the LLM, and the ddgs package lets your agent search the web via DuckDuckGo with zero API keys required.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir research-agent
cd research-agent
- Create a Python virtual environment by running:
python3 -m venv venv
What is a virtual environment?
A virtual environment is an isolated Python installation inside your project folder. Packages you install here stay separate from your system Python, so different projects never clash.
- Activate the virtual environment. The command differs by operating system:
macOS
source venv/bin/activate
Windows
venv\Scripts\activate
You should see (venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.
- Create a requirements.txt file that pins the exact package versions by running:
macOS
cat > requirements.txt << EOF
huggingface-hub==1.18.0
ddgs==9.14.4
python-dotenv==1.1.0
EOF
Windows
@"
huggingface-hub==1.18.0
ddgs==9.14.4
python-dotenv==1.1.0
"@ | Out-File -Encoding utf8 requirements.txt
What does this file do?
A requirements.txt locks your project to specific package versions. This ensures anyone (or any server) running your code installs the same dependencies you tested with.
- huggingface-hub provides the InferenceClient that calls the LLM.
- ddgs provides web search and page extraction via DuckDuckGo.
- python-dotenv loads your .env file so you never hardcode secrets.
- Install all three packages by running:
pip install -r requirements.txt
- Verify the packages installed correctly by running:
python -c "from huggingface_hub import InferenceClient; from ddgs import DDGS; from dotenv import load_dotenv; print('Ready!')"
You should see Ready! printed in your terminal.
Seeing an ImportError?
- Make sure your virtual environment is active. You should see (venv) at the start of your prompt.
- If the pip install command failed earlier, re-run it and check for red error messages about network connectivity or permissions.
- Confirm you are using Python 3.10 or higher by running python --version inside the venv.
Why can't Python find my packages?
Configure Hugging Face authentication
Your agent calls an LLM hosted on Hugging Face Inference Providers. To authenticate, you need a fine-grained token with the correct permission enabled.
- Open your browser and navigate to https://huggingface.co/settings/tokens.
- Click Create new token.
- Select Fine-grained as the token type.
- Give it a name like research-agent.
- Under permissions, enable Make calls to Inference Providers.
- Click Create token.
- Copy the token value that appears. You will only see it once.
Why a fine-grained token?
Fine-grained tokens let you grant only the specific permissions your project needs. If the token ever leaks, its blast radius is limited to calling Inference Providers. It cannot access your private repos or other Hugging Face resources.
- Back in your terminal, create a .env file to store your token by running:
macOS
echo "HF_TOKEN=your-hf-token-here" > .env
Windows
"HF_TOKEN=your-hf-token-here" | Out-File -Encoding utf8 .env
- Open the .env file in any text editor and replace your-hf-token-here with the actual token you copied from Hugging Face.
- Save the file.
- Verify your token works by running a quick test call to the Inference API:
python -c "
import os
from dotenv import load_dotenv
load_dotenv()
from huggingface_hub import InferenceClient
client = InferenceClient(api_key=os.environ.get('HF_TOKEN'))
response = client.chat.completions.create(
model='meta-llama/Llama-3.1-8B-Instruct',
messages=[{'role': 'user', 'content': 'Say hello in one word'}],
max_tokens=10
)
print(response.choices[0].message.content)
"
You should see a short response from the model (something like "Hello" or "Hi"). This confirms your token is valid and the free tier credits are working.
Getting a 401 Unauthorized error?
- Double-check that you pasted the full token into your .env file with no extra spaces or line breaks.
- Confirm you enabled the Make calls to Inference Providers permission when creating the token. If not, delete the token and create a new one with that permission.
- Make sure there are no quote characters around the token value in your .env file. It should be HF_TOKEN=hf_abc... with no quotes.
Why is my Hugging Face token not working?
Create the project file structure
Your research agent will be split across four Python files. Each handles a different responsibility: talking to the LLM, defining tools, running the agent loop, and tying everything together.
- Create the four project files by running:
macOS
touch llm.py tools.py agent.py main.py
Windows
New-Item llm.py, tools.py, agent.py, main.py
- Confirm the files exist by running:
ls
You should see .env, agent.py, llm.py, main.py, requirements.txt, tools.py, and the venv folder in the output.
- Now open VS Code in this folder. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Select the research-agent folder on your Desktop, then click Open.
You should see all four .py files, .env, and requirements.txt listed in the Explorer sidebar on the left.
What is each file for?
- llm.py wraps the Hugging Face client so your agent can talk to the LLM.
- tools.py defines the tools your agent can use (web search, page reading).
- agent.py contains the ReAct loop that orchestrates reasoning and tool calls.
- main.py is the entry point you run from the terminal.
✔️ Awesome, I've got everything!
Great. Double check you have saved your .env file with your real token and that your test API call returned a response.
ⓧ I'd like to double check the full code
Here is what your requirements.txt should look like:
huggingface-hub==1.18.0
ddgs==9.14.4
python-dotenv==1.1.0
And your .env file should look like this (with your actual token replacing the placeholder):
HF_TOKEN=your-hf-token-here
Your environment is ready and your agent has a brain to think with. Next up, you will build the LLM client that your agent calls every time it needs to reason.
Build the LLM Client
Your project environment is ready with all the packages installed and your Hugging Face token configured.
Now you need a way to talk to an LLM. The research agent's brain is a language model that reasons about questions, decides what tools to use, and writes final summaries. In this step, you will build a reusable LLM client that wraps Hugging Face's Inference Providers API so the rest of your agent can call it with a single function.
In this step, get ready to:
- Create an LLM client module that connects to Hugging Face Inference Providers.
- Test the client with a real API call to confirm your token and free credits work.
Create the LLM client module
The agent needs a clean interface to send messages and receive responses from a language model. You will build this as a standalone module so the agent logic stays separate from the API plumbing.
- In VS Code, open llm.py from the Explorer sidebar.
- Add the following code to set up the imports and initialize the client:
import os
from huggingface_hub import InferenceClient
# Initialize the Hugging Face client with your API token
client = InferenceClient(api_key=os.environ.get("HF_TOKEN"))
# The model we'll use for all agent reasoning
MODEL = "meta-llama/Llama-3.1-8B-Instruct"
What does this code do?
The InferenceClient is the Hugging Face SDK's entry point for calling hosted models. By passing your HF_TOKEN from the environment, the client authenticates every request automatically.
The MODEL constant points to meta-llama/Llama-3.1-8B-Instruct. This is an instruction-tuned model available on Hugging Face Inference Providers that is small enough to stay within free tier credits.
- Below the MODEL line, add the chat() function that sends messages to the model and returns its response:
def chat(messages):
"""Send messages to the LLM and return the assistant's response."""
response = client.chat.completions.create(
model=MODEL,
messages=messages,
max_tokens=1024,
temperature=0.7,
)
return response.choices[0].message.content
What does this code do?
The chat() function takes a list of message dictionaries (the conversation history) and sends them to the model using the OpenAI-compatible chat.completions.create() method.
It returns only the text content of the assistant's reply. The temperature=0.7 setting balances creativity with consistency. The max_tokens=1024 cap prevents runaway responses from eating through your free credits.
- Save llm.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete llm.py file at this point:
import os
from huggingface_hub import InferenceClient
client = InferenceClient(api_key=os.environ.get("HF_TOKEN"))
MODEL = "meta-llama/Llama-3.1-8B-Instruct"
def chat(messages):
"""Send messages to the LLM and return the assistant's response."""
response = client.chat.completions.create(
model=MODEL,
messages=messages,
max_tokens=1024,
temperature=0.7,
)
return response.choices[0].message.content
Test the LLM client standalone
Before building the agent, you need to confirm that your token works and the model responds. A quick test call proves the entire pipeline is functional.
- In your terminal, run this command to send a test message to the model:
python -c "from dotenv import load_dotenv; load_dotenv(); from llm import chat; print(chat([{'role': 'user', 'content': 'What is the ReAct pattern?'}]))"
- Wait a few seconds for the response. You should see a coherent explanation of the ReAct pattern printed in your terminal.
Why load_dotenv() first?
Your HF_TOKEN lives in the .env file, not in your system's environment variables. Calling load_dotenv() reads the .env file and makes those values available via os.environ. Later, main.py handles this automatically so you will not need to call it manually every time.
Not seeing a response?
- Check that your .env file contains HF_TOKEN= followed by your actual token (not the placeholder your-hf-token-here).
- Make sure the token was created with the "Make calls to Inference Providers" permission enabled.
- Confirm you are running the command from inside your project folder where .env and llm.py live.
Still stuck? Help me debug why my Hugging Face Inference Providers test call is failing.
What about free tier credits?
Hugging Face Inference Providers gives free users $0.10 in monthly credits. With a small model like Llama 3.1 8B, each request costs a tiny fraction of a cent. You can run dozens of test calls without worrying about the budget.
Your LLM client is working. Next up, you will build the tool system that gives your agent the ability to search the web and read pages.
Build the Tool System
Your LLM client is working and can talk to Llama 3.1. Now it needs something to talk about.
An agent without tools is just a chatbot. In this step, you will give your agent two capabilities: searching the web and reading web pages. These are the tools the agent will call autonomously to gather research data before writing its final summary.
In this step, get ready to:
- Build a web search tool that queries DuckDuckGo and returns formatted results.
- Build a page extraction tool that fetches and reads web page content.
- Create a tool registry that maps tool names to functions and descriptions.
Create the web search tool
The agent's first tool lets it search the internet. You will use the ddgs library, which wraps DuckDuckGo search in a simple Python API. No API key required.
- Open tools.py in VS Code (it should still be empty from Step 1).
- Add the import and the web search function by pasting this code:
from ddgs import DDGS
def web_search(query):
"""Search the web using DuckDuckGo and return formatted results."""
try:
results = DDGS().text(query, max_results=5)
if not results:
return "No results found."
output = []
for r in results:
output.append(f"Title: {r['title']}\nURL: {r['href']}\nSnippet: {r['body']}\n")
return "\n".join(output)
except Exception as e:
return f"Search error: {e}"
What does this code do?
- DDGS().text(query, max_results=5) performs a DuckDuckGo text search and returns up to 5 results as a list of dictionaries.
- Each result dictionary has keys title, href (the URL), and body (snippet text).
- The function formats these into a readable string the LLM can parse. The try/except block catches network errors gracefully instead of crashing the agent.
- Save tools.py.
- Test the search tool by running this command in your terminal:
python -c "from tools import web_search; print(web_search('Python programming'))"
You should see 5 search results printed, each with a Title, URL, and Snippet line.
Not seeing any results?
- Make sure you saved tools.py before running the test command.
- Check that your virtual environment is activated. You should see (venv) at the start of your terminal prompt.
- If you see a ModuleNotFoundError, run pip install ddgs again to reinstall the package.
Help me debug why my web_search function is not returning results
Create the page extraction tool
Search results give the agent titles and snippets, but not the full picture. The page extraction tool lets the agent actually read a web page's content, just like you would click on a search result to read the article.
- In tools.py, add the following function below web_search:
def read_page(url):
"""Extract and return the text content of a web page."""
try:
result = DDGS().extract(url, fmt="text_plain")
content = result.get("content", "")
if not content:
return "Could not extract content from this page."
return content[:3000]
except Exception as e:
return f"Extraction error: {e}"
What does this code do?
- DDGS().extract(url, fmt="text_plain") fetches a web page and converts its HTML to plain text.
- result.get("content", "") safely pulls the page text from the returned dictionary.
- content[:3000] truncates to 3000 characters. This prevents long pages from overloading the LLM's context window and burning through your free credits.
- Save tools.py.
- Test the page extraction tool by running:
python -c "from tools import read_page; print(read_page('https://example.com'))"
You should see plain text content from the example.com page printed in your terminal.
Seeing an extraction error?
- Make sure the URL you pass is a full URL including https://.
- If you see a timeout error, your network connection may be blocking DuckDuckGo requests. Try a different URL or check your firewall settings.
Help me debug why my read_page function is failing
Create the tool registry
The agent needs a way to know which tools exist and how to call them. A tool registry is a dictionary that maps each tool's name to its function and a plain-English description the LLM can read.
- In tools.py, add the tool registry and description helper below the read_page function:
TOOLS = {
"web_search": {
"function": web_search,
"description": "Search the web for a query. Input: a search query string.",
},
"read_page": {
"function": read_page,
"description": "Read and extract text content from a URL. Input: a full URL.",
},
}
def get_tools_description():
"""Generate a formatted description of all available tools."""
lines = []
for name, info in TOOLS.items():
lines.append(f"- {name}: {info['description']}")
return "\n".join(lines)
Why a tool registry?
The TOOLS dictionary is the single source of truth for all tools. When the agent parses a tool name from the LLM's output, it looks up that name in this dictionary to find the matching function.
get_tools_description() auto-generates a plain-English summary of every tool. This summary goes into the system prompt so the LLM knows what tools are available without you hardcoding the list in two places.
- Save tools.py.
- Verify the registry works by running:
python -c "from tools import get_tools_description; print(get_tools_description())"
You should see both tools listed with their descriptions, formatted as bullet points.
Seeing an import error?
- Make sure TOOLS and get_tools_description are defined at the module level (not indented inside another function).
- Check that there are no syntax errors like missing commas between dictionary entries.
Help me fix the import error in my tools.py tool registry
✔️ Awesome, I've got everything!
Great. Double check you have saved tools.py with all three sections: the web_search function, the read_page function, and the TOOLS registry with get_tools_description.
ⓧ I'd like to double check the full code
Here is the complete tools.py file. Make sure yours matches:
from ddgs import DDGS
def web_search(query):
"""Search the web using DuckDuckGo and return formatted results."""
try:
results = DDGS().text(query, max_results=5)
if not results:
return "No results found."
output = []
for r in results:
output.append(f"Title: {r['title']}\nURL: {r['href']}\nSnippet: {r['body']}\n")
return "\n".join(output)
except Exception as e:
return f"Search error: {e}"
def read_page(url):
"""Extract and return the text content of a web page."""
try:
result = DDGS().extract(url, fmt="text_plain")
content = result.get("content", "")
if not content:
return "Could not extract content from this page."
return content[:3000]
except Exception as e:
return f"Extraction error: {e}"
TOOLS = {
"web_search": {
"function": web_search,
"description": "Search the web for a query. Input: a search query string.",
},
"read_page": {
"function": read_page,
"description": "Read and extract text content from a URL. Input: a full URL.",
},
}
def get_tools_description():
"""Generate a formatted description of all available tools."""
lines = []
for name, info in TOOLS.items():
lines.append(f"- {name}: {info['description']}")
return "\n".join(lines)
Your agent now has two working tools and a registry that describes them. Next up, you will build the ReAct agent loop that uses these tools to reason through research questions step by step.
Build the ReAct Agent Loop
Your tools are ready. The agent now needs a brain that can reason about a question, decide which tool to call, observe the result, and repeat until it has enough information to answer.
This is the ReAct pattern (Reason + Act). The model thinks, takes an action, observes the outcome, then thinks again. In this step, you will build that loop from scratch in agent.py.
In this step, get ready to:
- Write the system prompt that teaches the model the Thought/Action/Observation format.
- Build a regex-based action parser that extracts tool calls from the model's output.
- Implement the full agent loop with conversation memory and termination logic.
Write the system prompt in agent.py
The system prompt is the instruction manual you hand to the LLM. It tells the model exactly what format to use for its responses so your code can reliably parse out tool calls.
- Open agent.py in VS Code and add the imports, system prompt, and regex pattern by pasting this code:
import re
from llm import chat
from tools import TOOLS, get_tools_description
# The system prompt instructs the LLM to follow the ReAct format
SYSTEM_PROMPT = f"""You are a research agent. Your job is to answer research questions thoroughly by searching the web and reading pages.
You have access to these tools:
{get_tools_description()}
You must follow this exact format for every response:
Thought: [your reasoning about what to do next]
Action: tool_name(argument)
After you receive an Observation (the tool's output), you will think again and decide the next step.
When you have gathered enough information to fully answer the question, respond with:
Thought: [your final reasoning]
Answer: [your complete, structured research summary in markdown format]
Rules:
- Always start with a Thought before any Action.
- Only call one tool per response.
- Never make up information. Only use what you find from tools.
- Aim to search at least 2-3 times and read at least 1 page before giving your Answer.
- Your final Answer should be well-structured with headings and bullet points.
"""
# Regex to extract tool calls like: Action: web_search(query here)
ACTION_PATTERN = re.compile(r"Action:\s*(\w+)\((.+)\)", re.DOTALL)
What does this code do?
The SYSTEM_PROMPT is an f-string that dynamically injects the available tool descriptions (from get_tools_description()) into the prompt at import time. This means adding a new tool to the registry automatically updates the agent's instructions.
The ACTION_PATTERN regex looks for lines like Action: web_search(some query) in the model's output. It captures two groups: the tool name and the argument. The re.DOTALL flag lets the argument span multiple lines if needed.
- Save agent.py.
- Verify the imports resolve correctly by running this in your terminal:
python agent.py
You should see no output and no errors. The file runs its module-level code (building the prompt string and compiling the regex) and exits silently.
Seeing an ImportError?
- If you see ModuleNotFoundError: No module named 'llm', make sure llm.py is in the same folder as agent.py. Python imports from the current directory.
- If you see ModuleNotFoundError: No module named 'ddgs', confirm your virtual environment is activated. Run pip install ddgs to reinstall.
- help me debug this ImportError in agent.py
Build the action parser
The agent loop needs a helper function that examines the model's text output and extracts the tool name and argument. This is how you bridge free-form LLM text into structured function calls.
- Below the ACTION_PATTERN line in agent.py, add the parse_action function:
def parse_action(text):
"""Parse an Action line from the model's response."""
match = ACTION_PATTERN.search(text)
if match:
# Extract the tool name and clean up the argument
tool_name = match.group(1).strip()
argument = match.group(2).strip().strip("\"'")
return tool_name, argument
return None, None
What does this code do?
The function searches the model's response for a line matching Action: tool_name(argument). If found, it returns a tuple of the tool name and the cleaned argument (with surrounding quotes stripped). If the model did not output a valid action, it returns (None, None) so the loop knows to stop.
You won't see a change yet. The next chunk uses this parser inside the main agent loop.
Add the agent loop with termination logic
The core loop calls the LLM, checks if it produced a final answer, parses any tool call, executes the tool, and feeds the result back as an observation. It repeats until one of three exit conditions is met.
- Below parse_action in agent.py, add the run_agent function:
def run_agent(question, max_steps=6):
"""Run the ReAct agent loop for a given research question."""
# Start conversation with system prompt and the user's question
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": question},
]
for step in range(1, max_steps + 1):
print(f"\n--- Step {step} ---")
# Call the LLM with the full conversation so far
response = chat(messages)
print(f"Agent: {response}")
# Exit condition 1: model produced a final answer
if "Answer:" in response:
answer = response.split("Answer:", 1)[1].strip()
return answer
# Try to parse a tool call from the response
tool_name, argument = parse_action(response)
# Exit condition 2: no valid action found
if tool_name is None:
print("(No valid action found, treating as final response)")
return response
# Dispatch to the correct tool or report an error
if tool_name not in TOOLS:
observation = f"Error: Unknown tool '{tool_name}'. Available tools: {', '.join(TOOLS.keys())}"
else:
print(f" -> Calling {tool_name}({argument})")
observation = TOOLS[tool_name]["function"](argument)
# Append the exchange to conversation memory
messages.append({"role": "assistant", "content": response})
messages.append({"role": "user", "content": f"Observation: {observation}"})
# Exit condition 3: max steps reached
return "Max steps reached. Could not complete the research in the allowed iterations."
How does the loop work?
Each iteration sends the full messages list to the LLM. The model sees every prior Thought, Action, and Observation, giving it memory of what it has already tried. This is conversation memory in action.
Tool dispatch uses the TOOLS dictionary from tools.py. The parsed tool name is a dictionary key, and TOOLS[tool_name]["function"](argument) calls the matching function. This design means adding a new tool is just one function plus one dict entry.
Three exit conditions prevent runaway loops: the model says Answer: (it is done), no valid action is parsed (graceful fallback), or max_steps is reached (hard cap at 6 iterations to conserve free-tier credits).
- Save agent.py.
- Confirm the full module loads without errors by running:
python agent.py
You should see no output again. The module defines functions but does not call them yet.
Seeing a SyntaxError?
- Check that the f-string in SYSTEM_PROMPT uses triple quotes (f"""..."""). A missing quote will break the entire file.
- Make sure the run_agent function's messages.append lines are indented inside the for loop, not outside it.
- help me fix a SyntaxError in my agent.py file
✔️ Awesome, I've got everything!
Great. Double check you have saved agent.py and that python agent.py runs without errors.
ⓧ I'd like to double check the full code
Here is the complete agent.py file at this point:
import re
from llm import chat
from tools import TOOLS, get_tools_description
SYSTEM_PROMPT = f"""You are a research agent. Your job is to answer research questions thoroughly by searching the web and reading pages.
You have access to these tools:
{get_tools_description()}
You must follow this exact format for every response:
Thought: [your reasoning about what to do next]
Action: tool_name(argument)
After you receive an Observation (the tool's output), you will think again and decide the next step.
When you have gathered enough information to fully answer the question, respond with:
Thought: [your final reasoning]
Answer: [your complete, structured research summary in markdown format]
Rules:
- Always start with a Thought before any Action.
- Only call one tool per response.
- Never make up information. Only use what you find from tools.
- Aim to search at least 2-3 times and read at least 1 page before giving your Answer.
- Your final Answer should be well-structured with headings and bullet points.
"""
ACTION_PATTERN = re.compile(r"Action:\s*(\w+)\((.+)\)", re.DOTALL)
def parse_action(text):
"""Parse an Action line from the model's response."""
match = ACTION_PATTERN.search(text)
if match:
tool_name = match.group(1).strip()
argument = match.group(2).strip().strip("\"'")
return tool_name, argument
return None, None
def run_agent(question, max_steps=6):
"""Run the ReAct agent loop for a given research question."""
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": question},
]
for step in range(1, max_steps + 1):
print(f"\n--- Step {step} ---")
response = chat(messages)
print(f"Agent: {response}")
if "Answer:" in response:
answer = response.split("Answer:", 1)[1].strip()
return answer
tool_name, argument = parse_action(response)
if tool_name is None:
print("(No valid action found, treating as final response)")
return response
if tool_name not in TOOLS:
observation = f"Error: Unknown tool '{tool_name}'. Available tools: {', '.join(TOOLS.keys())}"
else:
print(f" -> Calling {tool_name}({argument})")
observation = TOOLS[tool_name]["function"](argument)
messages.append({"role": "assistant", "content": response})
messages.append({"role": "user", "content": f"Observation: {observation}"})
return "Max steps reached. Could not complete the research in the allowed iterations."
Why text parsing instead of JSON tool calling?
Many agent frameworks use structured JSON for tool calls. However, smaller open models like Llama 3.1 8B sometimes struggle to produce consistent JSON output. Regex parsing of a simple Action: tool_name(argument) format is more robust at this model size.
It also teaches you the fundamentals. By writing the parser and dispatch logic yourself, you understand exactly what frameworks like LangChain and CrewAI automate under the hood.
Your agent's brain is wired up. Next, you will create the entry point that lets you ask it a real research question and watch it reason, search, and compile a report in real time.
Wire It All Together
You've built the LLM client, the tool system, and the full ReAct agent loop. Now it's time to connect all these pieces with an entry point that lets you actually use the agent from your terminal.
The main.py file needs to load your environment variables in the correct order, prompt you for a research question, and display the agent's reasoning as it works. This is where you'll finally see the entire pipeline come alive.
In this step, get ready to:
- Create the entry point that connects all modules.
- Run the agent end-to-end with a real research question.
- Verify the agent produces a structured research summary.
Create the entry point in main.py
When Python imports agent.py, it immediately imports llm.py, which reads your HF_TOKEN from the environment. If the token isn't loaded yet, the LLM client initializes with None and every API call fails.
The solution is to call load_dotenv() before importing the agent module.
- Open main.py in VS Code and add the environment loading code:
import os
from dotenv import load_dotenv
load_dotenv()
from agent import run_agent
Why is the import order unusual here?
Normally, all imports go at the top of a file. Here, load_dotenv() must run before from agent import run_agent because that import triggers a chain reaction.
Importing agent imports llm, which immediately reads os.environ.get("HF_TOKEN") at module level. If load_dotenv() hasn't run yet, the token is missing and every LLM call fails silently.
- Below the imports, add the main() function that handles user interaction and runs the agent:
def main():
print("=" * 50)
print(" Research Agent (ReAct Pattern)")
print("=" * 50)
print("\nAsk me a research question and I'll search the web,")
print("read pages, and compile a research summary.\n")
question = input("Your research question: ").strip()
if not question:
print("No question provided. Exiting.")
return
print(f"\nResearching: {question}")
print("-" * 50)
result = run_agent(question)
print("\n" + "=" * 50)
print(" RESEARCH SUMMARY")
print("=" * 50)
print(result)
if __name__ == "__main__":
main()
What does this code do?
- The header prints give the user context about what the agent does.
- input("Your research question: ").strip() pauses the script and waits for the user to type a question.
- The empty-question check exits gracefully if the user presses Enter without typing anything.
- run_agent(question) kicks off the full ReAct loop. The agent prints its Thoughts, Actions, and Observations as it works.
- The final section prints the research summary with a clear visual separator.
- Save main.py.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
import os
from dotenv import load_dotenv
load_dotenv()
from agent import run_agent
def main():
print("=" * 50)
print(" Research Agent (ReAct Pattern)")
print("=" * 50)
print("\nAsk me a research question and I'll search the web,")
print("read pages, and compile a research summary.\n")
question = input("Your research question: ").strip()
if not question:
print("No question provided. Exiting.")
return
print(f"\nResearching: {question}")
print("-" * 50)
result = run_agent(question)
print("\n" + "=" * 50)
print(" RESEARCH SUMMARY")
print("=" * 50)
print(result)
if __name__ == "__main__":
main()
Run the agent with a research question
All four modules are now complete. Time to see the agent reason through a real research task.
- Run the agent by entering this command in your terminal:
python main.py
- When prompted, type a research question. Try something like:
What are the pros and cons of microservices vs monoliths?
Watch the terminal output. You should see the agent cycling through numbered steps, each showing a Thought followed by an Action. After each action, an Observation appears with the tool's results.
After several steps, the agent outputs a final research summary formatted with headings and bullet points.
What should I see?
The output shows the agent's reasoning process in real time. You'll see it search the web, decide which pages to read, extract content, and eventually compile everything into a structured Markdown summary.
The whole process takes 30-60 seconds depending on the model's response time and how many steps the agent takes.
Seeing an error?
- If you see a 401 or authentication error, double check your .env file has a valid token and that "Make calls to Inference Providers" permission is enabled on your Hugging Face token.
- If you see ModuleNotFoundError, make sure your virtual environment is active and all packages are installed.
- If the agent loops without producing an answer, it may have hit the 6-step limit. Try a simpler question like "What is the ReAct pattern in AI?" to confirm the pipeline works.
Still stuck? Help me debug my research agent.
Troubleshoot common issues
LLMs are non-deterministic, so your agent may not follow the expected format perfectly every time. Here are the most common issues and how to handle them.
Model drifts from the format
If the agent outputs text without clear Thought: or Action: labels, the system prompt may need stronger wording. Try emphasizing "You MUST follow this exact format" in the system prompt inside agent.py.
Context gets too long
If the model starts producing incoherent responses after a few steps, it may be running out of context. You can reduce the 3000 character truncation limit in read_page() inside tools.py to something smaller like 1500.
- Test with 2-3 different questions to confirm the agent works reliably across topics.
- Try varying the complexity. A simple factual question and a broader comparison question will exercise different reasoning paths.
Secret mission
Add a Save-to-File Tool
Your agent researches and summarizes, but the output disappears as soon as you close the terminal. Can you extend the tool system so the agent saves its research report to a timestamped Markdown file automatically?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine and uses Hugging Face's free tier credits ($0.10/month). There are no ongoing cloud costs or paid infrastructure to worry about.
Resources you used:
- Project folder on your Desktop containing agent.py, tools.py, llm.py, main.py, .env, requirements.txt, and generated research report files.
- Python virtual environment inside the project folder.
- Hugging Face API token (created at huggingface.co/settings/tokens).
Keep everything running
No action needed. Choose this if you plan to keep experimenting with your research agent or want to add more tools.
- Your code is entirely local. There is nothing to shut down or pause.
- If you made many API calls during testing, check your Hugging Face Inference Providers usage at huggingface.co/settings/billing to see your remaining free credits.
Pause - I'll come back to this later
Since everything runs locally, there is nothing consuming resources while idle. Your files will sit quietly on your Desktop until you return.
- No processes to stop. The agent only runs when you execute python main.py.
- Your virtual environment stays dormant until you activate it again.
- Your Hugging Face free credits will continue accumulating monthly whether or not you use them.
Delete - I don't want to use this again
Remove all project files from your machine and revoke your API token.
- Delete the project folder from your Desktop. Open your terminal and run:
macOS
cd ~/Desktop
rm -rf research-agent
Windows
cd ~\Desktop
Remove-Item -Recurse -Force research-agent
This removes all project files, the virtual environment, your .env file, and any generated research report files.
- Revoke your Hugging Face API token. Go to huggingface.co/settings/tokens, find the token you created for this project, and click the delete button next to it.
Nice Work!
Nice Work!
You just built a fully functional research agent from scratch in pure Python. No frameworks, no magic, just your own code implementing the same ReAct loop that powers production agent systems like LangChain and CrewAI.
You've learned how to:
- Built a research agent from scratch in pure Python that autonomously searches the web, reads pages, and produces structured research summaries.
- Implemented the ReAct (Reasoning + Acting) loop with regex parsing, tool dispatch, conversation memory, and termination logic.
- Created a modular tool system where adding a new capability is a single function plus one dictionary entry.
- Secret Mission: Extended the agent with a save-to-file tool, proving the tool system is modular and extensible.
Ready to quiz yourself?