Build a Research Agent with LangGraph
Build an AI agent that autonomously searches the web and synthesizes findings.
Introduction
30 Second Summary
When you search for something online, you read a few pages, follow links, gather information from different sources, and piece together an answer. AI agents work the same way, but they do it autonomously.
In this project, you will build a research agent that takes any topic, searches the web, reads webpages, and produces a structured research summary. You will construct the entire agent loop from scratch so you understand exactly how autonomous AI decision-making works under the hood.
What You'll Build
A Python-based research agent powered by LangGraph and Google's Gemini API that autonomously searches the web and synthesizes findings into structured reports.
By the end of this project, you'll have:
- Two custom tools (web search and page reader) that your agent calls on its own.
- A LangGraph StateGraph implementing the ReAct pattern for autonomous decision-making.
- Conditional routing logic that lets your LLM decide when to gather more information and when to respond.
- Secret Mission: Add memory persistence so your agent can continue research across multiple sessions.
Are there any prerequisites?
You should have basic Python experience and be comfortable with concepts like functions, dictionaries, and installing packages with pip. Familiarity with APIs and environment variables is also helpful.
Before We Start
Before We Start...
Set Up Your Python Environment
Before your agent can search the web and synthesize research, it needs a foundation of Python packages that handle LLM communication, web searching, and graph-based orchestration.
In this step, you'll create an isolated project environment and install everything your research agent needs to run.
In this step, get ready to:
- Create a project directory with a Python virtual environment.
- Install all required packages.
- Verify your Gemini API key connects to the model.
Create your project directory and virtual environment
A virtual environment keeps your project's packages separate from your system Python. This prevents version conflicts with other projects on your machine.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar, then type Terminal (macOS) or PowerShell (Windows) and press Enter.
- 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 inside your project folder by running:
python3 -m venv venv
What does this command do?
python3 -m venv venv tells Python to create a virtual environment in a folder called venv. This folder contains a local copy of the Python interpreter and a place to install packages without affecting your system.
- Activate your virtual environment by running the command for your operating system:
macOS
source venv/bin/activate
Windows
.\venv\Scripts\activate
- Confirm the virtual environment is active by checking your terminal prompt.
You should see (venv) at the beginning of your terminal prompt. This confirms you're working inside the virtual environment.
Don't see (venv) in your prompt?
Make sure you ran the activation command for your operating system. On Windows, if you get a permissions error in PowerShell, try running Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned first, then retry the activation command.
help me activate my Python virtual environment
- Open your project folder in VS Code by running:
code .
code command not found?
If code . does not work, open VS Code manually. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar, then type VS Code and press Enter.
Once VS Code is open, click File in the top menu bar. Click Open Folder and select the research-agent folder on your Desktop.
Install the required packages
Your research agent relies on several packages. LangGraph handles the agent's decision loop. langchain-google-genai connects to Gemini. The remaining packages handle web searching and page reading.
- Install all the packages your agent needs by running:
pip install -U langgraph langchain-google-genai langchain duckduckgo-search requests beautifulsoup4
What are these packages?
- langgraph provides the graph-based orchestration framework for building your agent's reasoning loop.
- langchain-google-genai lets your code communicate with Google's Gemini models.
- langchain provides core abstractions for messages, tools, and tool calling.
- duckduckgo-search gives your agent free web search with no API key required.
- requests and beautifulsoup4 let your agent fetch and parse webpage content.
You should see a stream of download and installation messages ending with Successfully installed followed by a list of package names.
Installation failed?
Make sure you see (venv) in your prompt before running pip install. If you're outside the virtual environment, the install may fail due to permissions.
If you see a network timeout error, check your internet connection and try the command again.
help me fix my pip install error for the research agent packages
Set your API key and verify the connection
The langchain-google-genai package looks for an environment variable called GOOGLE_API_KEY to authenticate with Gemini. You'll set this in your terminal session now.
- Set your API key as an environment variable. Replace your-api-key-here with the actual key from Google AI Studio:
macOS
export GOOGLE_API_KEY=your-api-key-here
Windows
$env:GOOGLE_API_KEY = "your-api-key-here"
Why GOOGLE_API_KEY and not GEMINI_API_KEY?
The langchain-google-genai package uses GOOGLE_API_KEY as its primary environment variable name. Using GEMINI_API_KEY can cause authentication errors.
- Create a quick test script to verify your connection. In VS Code, create a new file called test_setup.py and add this content:
from langchain_google_genai import ChatGoogleGenerativeAI
# Quick test to verify your API key works
model = ChatGoogleGenerativeAI(model="gemini-2.5-flash")
response = model.invoke("Say hello in one sentence.")
print(response.content)
What does this script do?
This script imports the Gemini model wrapper, creates a model instance pointing to gemini-2.5-flash, sends it a simple prompt, and prints the response. If your API key is set correctly, you'll see a greeting from the model.
- Save test_setup.py and run the test script in your terminal:
python3 test_setup.py
You should see a short greeting message printed in your terminal. The exact wording varies, but any response confirms your API key and packages are working.
Seeing an authentication error?
Double-check that you set the variable as GOOGLE_API_KEY (not GEMINI_API_KEY). Also confirm your key was copied correctly with no extra spaces.
If you closed and reopened your terminal, you'll need to activate the virtual environment and set the environment variable again.
help me fix my Gemini API key authentication error
- Delete the test script now that you've confirmed everything works:
rm test_setup.py
Your environment is ready. Next up, you'll build the custom tools that give your agent the ability to search the web and read webpages.
Build Your Custom Tools
Your environment is ready with all the packages installed. Now it's time to give your agent its abilities.
An AI agent is only as useful as the tools it can call. In this step, you'll build two custom tools that your research agent will use to gather information: one for searching the web, and one for reading webpage content.
In this step, get ready to:
- Create a web search tool using DuckDuckGo.
- Create a webpage reader tool using BeautifulSoup.
- Test both tools to confirm they return real data.
Create the search_web tool
Your agent needs a way to search the internet for information on any topic. You'll use the duckduckgo-search library, which provides free web search without requiring an API key.
- Create a new file called research_agent.py in your research-agent folder by adding the following imports and first tool:
import os
from typing import Annotated, Literal
from typing_extensions import TypedDict
from langchain.messages import AnyMessage, SystemMessage, HumanMessage, ToolMessage
from langchain.tools import tool
from langchain_google_genai import ChatGoogleGenerativeAI
from langgraph.graph import StateGraph, START, END
from duckduckgo_search import DDGS
import requests
from bs4 import BeautifulSoup
import operator
# --- Tools ---
@tool
def search_web(query: str) -> str:
"""Search the web for information on a topic. Returns top search results with titles and snippets.
Args:
query: The search query string
"""
results = DDGS().text(query, max_results=5)
if not results:
return "No results found."
output = ""
for r in results:
output += f"Title: {r['title']}\nURL: {r['href']}\nSnippet: {r['body']}\n\n"
return output.strip()
What does this code do?
The imports at the top pull in everything you'll need across the project. Some (like StateGraph and ChatGoogleGenerativeAI) won't be used until later steps, but adding them now keeps the file organized.
The @tool decorator turns your Python function into a LangChain tool. The docstring is critical because the LLM reads it to decide when to call this tool.
Inside search_web, DDGS().text() runs a DuckDuckGo search and returns the top 5 results. Each result is formatted with its title, URL, and snippet so the LLM can read and reason about them.
- Save research_agent.py.
- You won't see any output yet. The next substep adds the second tool, and then you'll test both together.
Create the get_webpage_content tool
Search results give your agent titles and snippets, but sometimes it needs to read a full webpage for deeper information. This second tool fetches a URL and extracts clean text using BeautifulSoup.
- Add this second tool below your search_web function in research_agent.py:
@tool
def get_webpage_content(url: str) -> str:
"""Fetch and extract the main text content from a webpage URL.
Args:
url: The full URL of the webpage to read
"""
try:
response = requests.get(url, timeout=10, headers={"User-Agent": "ResearchAgent/1.0"})
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
for tag in soup(["script", "style", "nav", "footer", "header"]):
tag.decompose()
text = soup.get_text(separator="\n", strip=True)
return text[:3000]
except Exception as e:
return f"Error fetching page: {str(e)}"
What does this code do?
This tool fetches a webpage and strips out everything that isn't useful content. The decompose() calls remove script, style, navigation, footer, and header tags so the LLM only sees the main text.
The text[:3000] slice limits output to 3000 characters. This prevents overwhelming the LLM's context window with too much text from a single page.
The try/except block handles network errors gracefully. Instead of crashing, the tool returns a readable error message that the LLM can interpret and respond to.
- Save research_agent.py.
Test both tools independently
Before wiring these tools into an agent loop, you should confirm each one works on its own. You'll call each tool from the terminal and check that it returns real data.
- Test your search tool by running this command in your terminal:
python -c "from research_agent import search_web; print(search_web.invoke('Python programming'))"
- You should see output with 5 search results, each containing a title, URL, and snippet about Python programming.
Not seeing any results?
- Make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.
- Check that you're running the command from inside your research-agent folder where research_agent.py lives.
- If you see a ModuleNotFoundError, re-run your pip install command from Step 1 to make sure all packages are installed in your active environment.
Why isn't my search_web tool returning results?
- Test your webpage reader tool by running this command:
python -c "from research_agent import get_webpage_content; print(get_webpage_content.invoke('https://example.com'))"
- You should see clean text content from example.com, starting with something like Example Domain.
Seeing an error message instead of page content?
- If you see Error fetching page: followed by a connection error, check that you have internet access.
- If you see a SyntaxError, check that the quotes in your terminal command match exactly. The outer quotes should be double quotes and the inner quote around the URL should be single quotes.
Why is my get_webpage_content tool returning an error?
Why build custom tools instead of using prebuilt ones?
LangChain offers prebuilt tool packages, but building your own teaches you the pattern that production agents use. The @tool decorator, the docstring that describes the tool's purpose, and the return format are all things you'll customize when building real-world agents.
The LLM reads your docstrings to decide when to call each tool. Understanding this connection between tool descriptions and LLM decisions is the foundation of tool calling.
✔️ Awesome, I've got everything!
Great. Double check you've saved research_agent.py and both tools returned output in your terminal.
ⓧ I'd like to double check the full code
import os
from typing import Annotated, Literal
from typing_extensions import TypedDict
from langchain.messages import AnyMessage, SystemMessage, HumanMessage, ToolMessage
from langchain.tools import tool
from langchain_google_genai import ChatGoogleGenerativeAI
from langgraph.graph import StateGraph, START, END
from duckduckgo_search import DDGS
import requests
from bs4 import BeautifulSoup
import operator
# --- Tools ---
@tool
def search_web(query: str) -> str:
"""Search the web for information on a topic. Returns top search results with titles and snippets.
Args:
query: The search query string
"""
results = DDGS().text(query, max_results=5)
if not results:
return "No results found."
output = ""
for r in results:
output += f"Title: {r['title']}\nURL: {r['href']}\nSnippet: {r['body']}\n\n"
return output.strip()
@tool
def get_webpage_content(url: str) -> str:
"""Fetch and extract the main text content from a webpage URL.
Args:
url: The full URL of the webpage to read
"""
try:
response = requests.get(url, timeout=10, headers={"User-Agent": "ResearchAgent/1.0"})
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
for tag in soup(["script", "style", "nav", "footer", "header"]):
tag.decompose()
text = soup.get_text(separator="\n", strip=True)
return text[:3000]
except Exception as e:
return f"Error fetching page: {str(e)}"
Both tools are working and returning real data. Next up, you'll define the agent's state and build the graph structure that connects these tools to Gemini.
Define the Agent State and Graph Nodes
Your tools are ready to search the web and read pages. Now you need to give your agent a brain: the LLM that decides what to do, and a shared state that tracks the full conversation history.
In LangGraph, every graph has a state object that flows between nodes. Each node reads the state, does its work, and returns updates. In this step, you will define that state, configure the Gemini model with tool access, and build the two node functions that power the agent's reasoning loop.
In this step, get ready to:
- Define the agent's state schema for tracking message history.
- Create the LLM node that invokes Gemini with bound tools and a system prompt.
- Create the tool execution node that processes tool calls and returns results.
Define the agent's state schema
A StateGraph needs a typed state definition so it knows what data flows between nodes. Your agent's state is simple: a list of messages that grows as the conversation progresses.
- In research_agent.py, find the closing line of your get_webpage_content function. Add the following code below it:
# --- State ---
class AgentState(TypedDict):
messages: Annotated[list[AnyMessage], operator.add]
What does this code do?
AgentState is a TypedDict that defines the shape of data shared across all nodes in your graph.
The Annotated[list[AnyMessage], operator.add] type tells LangGraph to append new messages to the existing list instead of replacing it. This is how the conversation history accumulates across multiple reasoning cycles.
You won't see a change yet. The state definition is used by the graph you will build in the next step.
Create the LLM node with bound tools and a system prompt
The researcher node is the heart of your agent. It takes the current state, prepends a system prompt that tells Gemini how to behave, and invokes the model with tool access. If Gemini decides it needs more information, it returns tool calls instead of a final answer.
- Below the AgentState class you just added, paste the following code:
# --- Model Setup ---
model = ChatGoogleGenerativeAI(
model="gemini-2.5-flash",
temperature=0,
)
tools = [search_web, get_webpage_content]
tools_by_name = {t.name: t for t in tools}
model_with_tools = model.bind_tools(tools)
SYSTEM_PROMPT = """You are a thorough research assistant. When given a topic:
1. Search the web to find relevant information
2. Read specific pages for detailed content when needed
3. Synthesize your findings into a clear, structured summary
Always cite your sources. If you need more information, use your tools again.
When you have enough information to provide a comprehensive answer, respond directly without calling any tools."""
# --- Nodes ---
def researcher(state: AgentState):
"""The LLM node that reasons and decides whether to call tools."""
messages = [SystemMessage(content=SYSTEM_PROMPT)] + state["messages"]
response = model_with_tools.invoke(messages)
return {"messages": [response]}
What does this code do?
The model setup section configures Gemini for your agent:
- model creates a ChatGoogleGenerativeAI instance using gemini-2.5-flash with temperature=0 for deterministic research results.
- tools_by_name builds a lookup dictionary mapping each tool's name to its function. The tool executor node uses this to find the right function when the LLM asks for a tool.
- model.bind_tools(tools) tells Gemini about your tools' names, descriptions, and parameter schemas. This is how the LLM knows what tools exist and when to call them.
- SYSTEM_PROMPT instructs the model to behave as a research assistant that searches, reads pages, and synthesizes a final answer.
- researcher() is your LLM node. It prepends the system prompt to the message history and invokes the model. The response (which may contain tool calls or a final answer) gets appended to the state.
You won't see visible output yet. The next substep adds the tool executor that completes the node pair.
Create the tool execution node
When Gemini decides to use a tool, it returns one or more tool call objects in its response. The tool executor reads those calls, runs each tool, and returns the results as ToolMessage objects that flow back into the conversation.
- Below the researcher function, add the tool executor node:
def tool_executor(state: AgentState):
"""Execute tool calls from the LLM response."""
last_message = state["messages"][-1]
results = []
for tool_call in last_message.tool_calls:
tool_fn = tools_by_name[tool_call["name"]]
result = tool_fn.invoke(tool_call["args"])
results.append(
ToolMessage(content=str(result), tool_call_id=tool_call["id"])
)
return {"messages": results}
What does this code do?
- state["messages"][-1] grabs the most recent message, which is the LLM's response containing tool calls.
- The loop iterates over each tool call. Each tool call has a name (which tool to use), args (the parameters), and an id (to match results back to their call).
- tools_by_name[tool_call["name"]] looks up the matching tool function (e.g. search_web or get_webpage_content).
- Each result is wrapped in a ToolMessage with the matching tool_call_id so the LLM can associate results with the correct call.
- Save research_agent.py.
- Confirm there are no syntax errors by running:
python research_agent.py
- The script should exit silently with no output and no errors. This confirms your Python syntax is correct and all imports resolve.
Seeing an ImportError or NameError?
- Make sure your virtual environment is still activated. You should see (venv) at the start of your terminal prompt.
- Check that AgentState is defined before the researcher and tool_executor functions. Python reads top to bottom, so the class must come first.
- Verify your import block at the top of the file includes import operator and from langchain_google_genai import ChatGoogleGenerativeAI.
Why is my research_agent.py throwing an ImportError?
Why two separate node functions?
The ReAct pattern separates reasoning from action. The researcher node reasons about what to do next. The tool_executor node carries out the action.
This separation lets the graph route between them conditionally. If the LLM wants more data, execution flows to the tool executor and back. If the LLM has a final answer, execution ends. You will wire this routing logic in the next step.
✔️ Awesome, I've got everything!
Great. Double check you have saved research_agent.py and that the script runs without errors.
ⓧ I'd like to double check the full code
import os
from typing import Annotated, Literal
from typing_extensions import TypedDict
from langchain.messages import AnyMessage, SystemMessage, HumanMessage, ToolMessage
from langchain.tools import tool
from langchain_google_genai import ChatGoogleGenerativeAI
from langgraph.graph import StateGraph, START, END
from duckduckgo_search import DDGS
import requests
from bs4 import BeautifulSoup
import operator
# --- Tools ---
@tool
def search_web(query: str) -> str:
"""Search the web for information on a topic. Returns top search results with titles and snippets.
Args:
query: The search query string
"""
results = DDGS().text(query, max_results=5)
if not results:
return "No results found."
output = ""
for r in results:
output += f"Title: {r['title']}\nURL: {r['href']}\nSnippet: {r['body']}\n\n"
return output.strip()
@tool
def get_webpage_content(url: str) -> str:
"""Fetch and extract the main text content from a webpage URL.
Args:
url: The full URL of the webpage to read
"""
try:
response = requests.get(url, timeout=10, headers={"User-Agent": "ResearchAgent/1.0"})
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
for tag in soup(["script", "style", "nav", "footer", "header"]):
tag.decompose()
text = soup.get_text(separator="\n", strip=True)
return text[:3000]
except Exception as e:
return f"Error fetching page: {str(e)}"
# --- State ---
class AgentState(TypedDict):
messages: Annotated[list[AnyMessage], operator.add]
# --- Model Setup ---
model = ChatGoogleGenerativeAI(
model="gemini-2.5-flash",
temperature=0,
)
tools = [search_web, get_webpage_content]
tools_by_name = {t.name: t for t in tools}
model_with_tools = model.bind_tools(tools)
SYSTEM_PROMPT = """You are a thorough research assistant. When given a topic:
1. Search the web to find relevant information
2. Read specific pages for detailed content when needed
3. Synthesize your findings into a clear, structured summary
Always cite your sources. If you need more information, use your tools again.
When you have enough information to provide a comprehensive answer, respond directly without calling any tools."""
# --- Nodes ---
def researcher(state: AgentState):
"""The LLM node that reasons and decides whether to call tools."""
messages = [SystemMessage(content=SYSTEM_PROMPT)] + state["messages"]
response = model_with_tools.invoke(messages)
return {"messages": [response]}
def tool_executor(state: AgentState):
"""Execute tool calls from the LLM response."""
last_message = state["messages"][-1]
results = []
for tool_call in last_message.tool_calls:
tool_fn = tools_by_name[tool_call["name"]]
result = tool_fn.invoke(tool_call["args"])
results.append(
ToolMessage(content=str(result), tool_call_id=tool_call["id"])
)
return {"messages": results}
Your agent now has its brain (the researcher node) and its hands (the tool executor node). Next up, you will wire these nodes together into a graph with conditional routing so the agent can loop between reasoning and acting until it has a complete answer.
Wire Up the ReAct Loop
Your agent now has tools, state, and two node functions ready to go. The missing piece is the ReAct loop itself. This is the control flow that decides what happens after each LLM response.
In this step, you will wire up the routing logic, connect everything into a StateGraph, and add a runner script so you can test the agent end-to-end.
In this step, get ready to:
- Build the routing function that decides when to continue or stop.
- Connect nodes into a StateGraph with conditional edges.
- Compile the graph and add a runner script to test the agent.
Add the routing logic
The ReAct loop needs a decision point after every LLM response. If the LLM decided to call a tool, the loop should route back to the tool executor. If the LLM responded with a final answer (no tool calls), the loop should end.
- In research_agent.py, add this routing function below the tool_executor function:
# --- Routing ---
def should_continue(state: AgentState) -> Literal["tool_executor", "__end__"]:
"""Route to tools if the LLM made tool calls, otherwise end."""
last_message = state["messages"][-1]
if last_message.tool_calls:
return "tool_executor"
return "__end__"
What does this code do?
The should_continue function checks the last message from the LLM. If it contains tool calls, the function returns "tool_executor" to route the graph to the tool execution node.
If there are no tool calls, the LLM is done researching and has produced a final answer. The function returns "__end__" to terminate the loop. This is the core decision mechanism of the ReAct pattern.
You won't see a change yet. The next chunk builds the graph that uses this routing function.
Build the StateGraph with conditional edges
A LangGraph StateGraph connects your nodes and defines how data flows between them. Conditional edges let you branch the flow based on the output of a node.
- Below the should_continue function, add the graph construction code:
# --- Build Graph ---
graph_builder = StateGraph(AgentState)
# Register both nodes
graph_builder.add_node("researcher", researcher)
graph_builder.add_node("tool_executor", tool_executor)
# Define edges: START -> researcher, then conditional routing
graph_builder.add_edge(START, "researcher")
graph_builder.add_conditional_edges("researcher", should_continue, ["tool_executor", END])
graph_builder.add_edge("tool_executor", "researcher")
# Compile the graph into a runnable agent
agent = graph_builder.compile()
What does this code do?
- StateGraph(AgentState) creates a new graph that uses your state schema to track messages.
- add_node registers each function as a named node the graph can route to.
- add_edge(START, "researcher") means the graph always begins at the researcher node.
- add_conditional_edges uses should_continue to decide whether to go to tool_executor or END.
- add_edge("tool_executor", "researcher") sends tool results back to the LLM for the next reasoning step. This creates the loop.
- compile() finalizes the graph into a runnable object you can invoke.
Why a graph instead of a simple while loop?
A graph makes each step explicit and inspectable. You can see exactly which node ran, what it returned, and where the flow went next. In production, this lets you add logging, retries, and human-in-the-loop checkpoints without rewriting your control flow.
You won't see a change yet. The next chunk adds a runner script so you can test the full agent.
Add the runner script
The graph is built, but you need an entry point to actually invoke it. A standard Python __main__ block lets you run the script from the terminal and feed it a research topic.
- At the very bottom of research_agent.py, add the runner block:
# --- Run ---
if __name__ == "__main__":
topic = input("What would you like me to research? ")
print(f"\nResearching: {topic}\n" + "=" * 50)
# Invoke the agent with the user's topic as a HumanMessage
result = agent.invoke({"messages": [HumanMessage(content=topic)]})
# Print the final response from the agent
final_message = result["messages"][-1]
print("\n" + "=" * 50)
print("RESEARCH FINDINGS:")
print("=" * 50)
print(final_message.content)
What does this code do?
- input() prompts you to type a research topic in the terminal.
- agent.invoke() starts the ReAct loop by passing your topic as a HumanMessage. The agent loops through researcher and tool_executor nodes until should_continue returns "__end__".
- result["messages"][-1] grabs the last message in the conversation, which is the agent's final synthesized answer.
- Save research_agent.py.
- Confirm the script loads without errors by running:
python research_agent.py
- You should see the prompt What would you like me to research? appear in your terminal.
- Press Ctrl+C to exit for now. You will run a full research session in the next step.
Seeing an error instead of the prompt?
- A NameError for should_continue or researcher means the function was defined below where it is referenced. Make sure should_continue appears above the # --- Build Graph --- section.
- An ImportError means a package is missing. Make sure your virtual environment is activated and run pip install langgraph langchain-google-genai again.
- Check that your GOOGLE_API_KEY environment variable is still set in this terminal session.
Help me debug my research agent script
✔️ Awesome, I've got everything!
Great. Double check you have saved research_agent.py and you are ready for the next step.
ⓧ I'd like to double check the full code
import os
from typing import Annotated, Literal
from typing_extensions import TypedDict
from langchain.messages import AnyMessage, SystemMessage, HumanMessage, ToolMessage
from langchain.tools import tool
from langchain_google_genai import ChatGoogleGenerativeAI
from langgraph.graph import StateGraph, START, END
from duckduckgo_search import DDGS
import requests
from bs4 import BeautifulSoup
import operator
# --- Tools ---
@tool
def search_web(query: str) -> str:
"""Search the web for information on a topic. Returns top search results with titles and snippets.
Args:
query: The search query string
"""
results = DDGS().text(query, max_results=5)
if not results:
return "No results found."
output = ""
for r in results:
output += f"Title: {r['title']}\nURL: {r['href']}\nSnippet: {r['body']}\n\n"
return output.strip()
@tool
def get_webpage_content(url: str) -> str:
"""Fetch and extract the main text content from a webpage URL.
Args:
url: The full URL of the webpage to read
"""
try:
response = requests.get(url, timeout=10, headers={"User-Agent": "ResearchAgent/1.0"})
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
for tag in soup(["script", "style", "nav", "footer", "header"]):
tag.decompose()
text = soup.get_text(separator="\n", strip=True)
return text[:3000]
except Exception as e:
return f"Error fetching page: {str(e)}"
# --- State ---
class AgentState(TypedDict):
messages: Annotated[list[AnyMessage], operator.add]
# --- Model Setup ---
model = ChatGoogleGenerativeAI(
model="gemini-2.5-flash",
temperature=0,
)
tools = [search_web, get_webpage_content]
tools_by_name = {t.name: t for t in tools}
model_with_tools = model.bind_tools(tools)
SYSTEM_PROMPT = """You are a thorough research assistant. When given a topic:
1. Search the web to find relevant information
2. Read specific pages for detailed content when needed
3. Synthesize your findings into a clear, structured summary
Always cite your sources. If you need more information, use your tools again.
When you have enough information to provide a comprehensive answer, respond directly without calling any tools."""
# --- Nodes ---
def researcher(state: AgentState):
"""The LLM node that reasons and decides whether to call tools."""
messages = [SystemMessage(content=SYSTEM_PROMPT)] + state["messages"]
response = model_with_tools.invoke(messages)
return {"messages": [response]}
def tool_executor(state: AgentState):
"""Execute tool calls from the LLM response."""
last_message = state["messages"][-1]
results = []
for tool_call in last_message.tool_calls:
tool_fn = tools_by_name[tool_call["name"]]
result = tool_fn.invoke(tool_call["args"])
results.append(
ToolMessage(content=str(result), tool_call_id=tool_call["id"])
)
return {"messages": results}
# --- Routing ---
def should_continue(state: AgentState) -> Literal["tool_executor", "__end__"]:
"""Route to tools if the LLM made tool calls, otherwise end."""
last_message = state["messages"][-1]
if last_message.tool_calls:
return "tool_executor"
return "__end__"
# --- Build Graph ---
graph_builder = StateGraph(AgentState)
graph_builder.add_node("researcher", researcher)
graph_builder.add_node("tool_executor", tool_executor)
graph_builder.add_edge(START, "researcher")
graph_builder.add_conditional_edges("researcher", should_continue, ["tool_executor", END])
graph_builder.add_edge("tool_executor", "researcher")
agent = graph_builder.compile()
# --- Run ---
if __name__ == "__main__":
topic = input("What would you like me to research? ")
print(f"\nResearching: {topic}\n" + "=" * 50)
result = agent.invoke({"messages": [HumanMessage(content=topic)]})
final_message = result["messages"][-1]
print("\n" + "=" * 50)
print("RESEARCH FINDINGS:")
print("=" * 50)
print(final_message.content)
Your research agent is fully wired up and ready to go. Next up, you will run the agent on real research topics and watch it reason, search, and synthesize information autonomously.
Run Your Research Agent
Your graph is compiled and every component is wired together. The researcher node, the tool_executor node, and the should_continue routing function are all in place.
In this step, you'll run the agent and watch the ReAct loop in action. You'll see the agent reason about your topic, call tools, gather information, and deliver a structured research report.
In this step, get ready to:
- Run the agent with a research topic and observe the full reasoning trace.
- Watch the agent decide which tools to call and synthesize a final answer.
- Test with a different topic to see how the agent adapts its strategy.
Invoke the agent with a research topic
The __main__ block in your script prompts you for a topic, passes it into the graph as a HumanMessage, and prints the final response. The agent takes care of everything in between.
- Make sure your virtual environment is activated and your GOOGLE_API_KEY environment variable is set.
- Run your research agent by running this command:
python research_agent.py
- The script will ask: What would you like me to research?
- Type a research topic and press Enter. Try something like What are the latest advances in quantum computing?
The agent will take a few seconds to process. You'll see the prompt Researching: <your topic> followed by a separator line while it works.
What's happening behind the scenes?
While you wait, the agent is running through the ReAct loop you built. First, the researcher node sends your topic to Gemini along with the system prompt and available tools.
Gemini decides to call search_web first. The should_continue function detects the tool call and routes to tool_executor. After executing the search, control flows back to researcher to decide the next step. This loop repeats until Gemini has enough information and responds without calling any tools.
After several seconds, you'll see the output header RESEARCH FINDINGS: followed by a structured summary with sources cited.
Seeing a timeout or connection error?
If you see an error about a timeout or connection failure, the DuckDuckGo search might be temporarily rate-limited. Wait 30 seconds and try again.
If you see a DefaultCredentialsError or authentication error, make sure your GOOGLE_API_KEY environment variable is set correctly in the same terminal session where you're running the script.
help me debug my research agent error
Trace the ReAct loop in the output
The final printed output is the agent's synthesized answer. But the real learning happens when you understand the path the agent took to get there.
- Look at your research findings output. The agent cites URLs it found through search_web and may include details it pulled via get_webpage_content.
How do I know which tools the agent used?
The result variable contains the full message history in result["messages"]. Each tool call and tool result is stored as a separate message. The agent typically calls search_web first to find relevant URLs, then calls get_webpage_content on the most promising results.
The number of loop iterations varies by topic complexity. A simple factual question might need one search. A broad topic might trigger two searches and several page reads before the agent is satisfied.
The ReAct flow in your agent
- researcher node receives the messages and invokes Gemini with tools bound.
- should_continue checks whether Gemini's response includes tool calls.
- If yes: routes to tool_executor, which runs the tool and appends the result as a ToolMessage. Then control returns to researcher.
- If no: the agent has enough information and the graph ends. The final message content is your research report.
Test with a different topic
A good agent adapts its strategy based on what it finds. Testing with a different kind of topic shows you how the same graph structure handles different research needs.
- Run the agent again by running this command:
python research_agent.py
- This time, try a different style of topic. For example, try something more specific like How does WebAssembly compare to JavaScript for performance?
- Compare the output to your first run. Notice whether the agent called different tools or made more loop iterations this time.
Why does the agent behave differently?
The LLM reads the search results and decides whether it has enough information. A narrow factual question might be answered after a single search_web call. A comparative or technical question often triggers get_webpage_content calls so the agent can read detailed pages before synthesizing.
This adaptive behaviour is the power of the ReAct pattern. You didn't hardcode when to search or when to read pages. The LLM makes that decision on every loop iteration based on what it already knows.
Getting a 429 rate limit error?
The Gemini free tier has daily request limits. If you've been running the agent many times, you may hit this limit. Wait a few minutes before trying again.
help me fix my Gemini rate limit error
✔️ Awesome, I've got everything!
Great. Double check you've saved your file.
ⓧ I'd like to double check the full code
import os
from typing import Annotated, Literal
from typing_extensions import TypedDict
from langchain.messages import AnyMessage, SystemMessage, HumanMessage, ToolMessage
from langchain.tools import tool
from langchain_google_genai import ChatGoogleGenerativeAI
from langgraph.graph import StateGraph, START, END
from duckduckgo_search import DDGS
import requests
from bs4 import BeautifulSoup
import operator
# --- Tools ---
@tool
def search_web(query: str) -> str:
"""Search the web for information on a topic. Returns top search results with titles and snippets.
Args:
query: The search query string
"""
results = DDGS().text(query, max_results=5)
if not results:
return "No results found."
output = ""
for r in results:
output += f"Title: {r['title']}\nURL: {r['href']}\nSnippet: {r['body']}\n\n"
return output.strip()
@tool
def get_webpage_content(url: str) -> str:
"""Fetch and extract the main text content from a webpage URL.
Args:
url: The full URL of the webpage to read
"""
try:
response = requests.get(url, timeout=10, headers={"User-Agent": "ResearchAgent/1.0"})
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
for tag in soup(["script", "style", "nav", "footer", "header"]):
tag.decompose()
text = soup.get_text(separator="\n", strip=True)
return text[:3000]
except Exception as e:
return f"Error fetching page: {str(e)}"
# --- State ---
class AgentState(TypedDict):
messages: Annotated[list[AnyMessage], operator.add]
# --- Model Setup ---
model = ChatGoogleGenerativeAI(
model="gemini-2.5-flash",
temperature=0,
)
tools = [search_web, get_webpage_content]
tools_by_name = {t.name: t for t in tools}
model_with_tools = model.bind_tools(tools)
SYSTEM_PROMPT = """You are a thorough research assistant. When given a topic:
1. Search the web to find relevant information
2. Read specific pages for detailed content when needed
3. Synthesize your findings into a clear, structured summary
Always cite your sources. If you need more information, use your tools again.
When you have enough information to provide a comprehensive answer, respond directly without calling any tools."""
# --- Nodes ---
def researcher(state: AgentState):
"""The LLM node that reasons and decides whether to call tools."""
messages = [SystemMessage(content=SYSTEM_PROMPT)] + state["messages"]
response = model_with_tools.invoke(messages)
return {"messages": [response]}
def tool_executor(state: AgentState):
"""Execute tool calls from the LLM response."""
last_message = state["messages"][-1]
results = []
for tool_call in last_message.tool_calls:
tool_fn = tools_by_name[tool_call["name"]]
result = tool_fn.invoke(tool_call["args"])
results.append(
ToolMessage(content=str(result), tool_call_id=tool_call["id"])
)
return {"messages": results}
# --- Routing ---
def should_continue(state: AgentState) -> Literal["tool_executor", "__end__"]:
"""Route to tools if the LLM made tool calls, otherwise end."""
last_message = state["messages"][-1]
if last_message.tool_calls:
return "tool_executor"
return "__end__"
# --- Build Graph ---
graph_builder = StateGraph(AgentState)
graph_builder.add_node("researcher", researcher)
graph_builder.add_node("tool_executor", tool_executor)
graph_builder.add_edge(START, "researcher")
graph_builder.add_conditional_edges("researcher", should_continue, ["tool_executor", END])
graph_builder.add_edge("tool_executor", "researcher")
agent = graph_builder.compile()
# --- Run ---
if __name__ == "__main__":
topic = input("What would you like me to research? ")
print(f"\nResearching: {topic}\n" + "=" * 50)
result = agent.invoke({"messages": [HumanMessage(content=topic)]})
final_message = result["messages"][-1]
print("\n" + "=" * 50)
print("RESEARCH FINDINGS:")
print("=" * 50)
print(final_message.content)
Secret mission
Add Memory Persistence
Your agent is powerful, but it forgets everything after each run. What if you could ask follow-up questions and have the agent remember what it already researched? Add memory persistence to unlock multi-turn research conversations.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. The Gemini API free tier does not charge for usage within its limits.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Project folder with research_agent.py and Python virtual environment.
- Google AI Studio API key (GOOGLE_API_KEY environment variable).
- Python packages installed in the virtual environment (langgraph, langchain-google-genai, duckduckgo-search, requests, beautifulsoup4).
Keep everything running
No action needed. Choose this if you plan to keep experimenting with your research agent or extend it with new tools.
- Your project folder stays on your Desktop.
- Your virtual environment stays ready to activate whenever you return.
- Your API key remains active in Google AI Studio. Unused keys may eventually be blocked by Google, but you can generate a new one at any time.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files and API key stay intact for when you return.
- Close any terminal windows running the agent.
- Deactivate your virtual environment by running:
deactivate
- When you come back, reactivate it with source venv/bin/activate (macOS/Linux) or venv\Scripts\activate (Windows).
Delete - I don't want to use this again
Remove all project resources and start fresh. This cannot be undone.
- Delete your project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/research-agent
- On Windows, run this instead:
rmdir /s /q %USERPROFILE%\Desktop\research-agent
This removes your Python files, virtual environment, and all installed packages in one step.
- Delete your Gemini API key by visiting the Google AI Studio API Keys page at aistudio.google.com/app/apikey.
- Find the key you created for this project, click the trash icon next to it, and confirm deletion.
- Remove the GOOGLE_API_KEY environment variable from your shell configuration file (.bashrc, .zshrc, or Windows environment variables) if you added it there.
Nice Work!
Nice Work!
You just built a production-style AI research agent from scratch using LangGraph and Gemini. Your agent autonomously searches the web, reads pages, reasons about what it finds, and synthesizes structured research summaries.
You've learned how to:
- Build custom tools (web search and page reader) that an LLM calls autonomously based on docstring descriptions.
- Construct a LangGraph StateGraph implementing the ReAct pattern with nodes, edges, and state management.
- Wire conditional routing that lets the LLM decide when to use tools and when to respond to the user.
- Add memory persistence for multi-turn research conversations so the agent remembers previous findings.
Ready to quiz yourself?