Build a Multi-Agent Research Assistant
Orchestrate specialized AI agents locally with LangGraph and Ollama.
Introduction
30 Second Summary
Asking a single AI chatbot to research, write, and answer questions means it tries to juggle everything at once. A smarter approach is to split the work across specialized agents, each coordinated by a supervisor that knows exactly who to call.
In this project, you will build a multi-agent research assistant that runs entirely on your Mac using LangGraph and Ollama. A supervisor agent analyzes your requests and routes them to three specialists: a search agent, a writer agent, and a Q&A agent.
What You'll Build
A local AI research assistant powered by a multi-agent supervisor architecture, where a central supervisor routes tasks to specialized worker agents through a shared state graph.
By the end of this project, you'll have:
- A supervisor agent that analyzes each request and dynamically routes it to the right specialist using LangGraph's Command API.
- Three worker agents (search, writer, Q&A) that collaborate through shared typed state to research topics, draft summaries, and answer follow-up questions.
- A human-in-the-loop approval step that pauses the graph before the writer agent saves files to disk, giving you full control over what gets written.
- A critic agent (Secret Mission) that reviews the writer's drafts and creates a revision loop before human approval.
Are there any prerequisites?
This project assumes intermediate Python experience and a basic understanding of how LLMs work. You don't need prior experience with LangGraph or LangChain.
Before We Start
Before We Start...
Set Up Your Project Environment
Your multi-agent research assistant needs a few key ingredients before you can start building. Ollama runs the LLM locally on your machine. A handful of Python packages wire everything together.
In this step, you will create a project folder, install the required packages, and confirm that Ollama can serve the qwen3:8b model.
In this step, get ready to:
- Create a project directory with a Python virtual environment.
- Install LangGraph, LangChain, and DuckDuckGo search packages.
- Verify Ollama is serving the qwen3:8b model.
Create your project directory and install packages
Every Python project benefits from its own isolated folder and virtual environment. A virtual environment keeps your project's packages separate from system-wide Python packages.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal 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-assistant
cd research-assistant
- Create a Python virtual environment by running this command:
python3 -m venv .venv
- Activate the virtual environment by running:
macOS
source .venv/bin/activate
Windows
.venv\Scripts\activate
You should see (.venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.
Don't see (.venv) in your prompt?
Make sure you ran the activate command from inside the research-assistant folder. Run pwd to check your current directory.
If Python 3.10 is not your default, try replacing python3 with python3.10 when creating the virtual environment.
Help me activate my Python virtual environment.
- Install all the required packages by running this command:
pip install langgraph==1.1.9 langchain-ollama==1.1.0 langchain-community==0.4.1 langchain-core==1.3.0 ddgs==9.14.1
What are these packages?
- langgraph is the framework for building multi-agent graphs with state, nodes, and edges.
- langchain-ollama connects LangChain to your local Ollama models.
- langchain-community includes community-built integrations like the DuckDuckGo search tool.
- langchain-core provides the core abstractions for messages, tools, and prompts.
- ddgs is the backend that powers DuckDuckGo searches from Python.
The installation takes about a minute. When it finishes, you should see Successfully installed followed by a list of packages in your terminal output.
Seeing installation errors?
Make sure your virtual environment is active. You should see (.venv) in your terminal prompt before running pip install.
If you see a permissions error, do not use sudo. Deactivate and recreate the virtual environment instead.
Help me fix my pip install errors.
Verify Ollama is running with qwen3:8b
Ollama serves LLM models as a local API on your machine. Your agents will send requests to this API instead of calling a cloud service.
- Check if Ollama is running by listing your installed models:
ollama list
I see a list of models
Ollama is running. If you see qwen3:8b in the list, skip the next pull command and jump straight to the test prompt below.
Command not found
Ollama is not installed. Visit ollama.com to download and install Ollama for your operating system.
After installing, open Ollama by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Ollama, and pressing Enter.
- Try ollama list again once Ollama is running.
- Pull the qwen3:8b model by running this command:
ollama pull qwen3:8b
Why qwen3:8b?
Qwen3 has the most stable tool calling of any Ollama model family. The 8B parameter size fits comfortably in 16GB of RAM at about 5.2GB on disk.
Qwen3 was trained with agent capabilities and works in both thinking and non-thinking modes. This makes it a strong choice for multi-agent systems where each agent needs to call tools reliably.
This download is about 5.2GB. It may take a few minutes depending on your connection speed. You will see a progress bar in your terminal.
- Once the pull completes, test the model with a quick prompt by running:
ollama run qwen3:8b "Say hello in one sentence."
You should see a short greeting from the model. The response may include <think> tags before the actual answer. That is normal because Qwen3 defaults to a "thinking" mode where it reasons before responding.
- Type /bye and press Enter to exit the Ollama interactive session.
Model not responding?
If you see a connection error, Ollama's background service may not be running. Open the Ollama app from Spotlight search or your Start menu, then try the command again.
If the model responds very slowly or hangs, make sure no other large applications are consuming your RAM. Close unused apps and try again.
Help me get Ollama and qwen3:8b working.
Create the project file and test your setup
Now you will create the main project file and run a quick test to confirm that Python can talk to Ollama through the packages you installed.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Select the research-assistant folder on your Desktop, then click Open.
- Create a new file by pressing Cmd+N (macOS) or Ctrl+N (Windows).
- Save the file immediately with Cmd+S (macOS) or Ctrl+S (Windows). Name it research_assistant.py.
You now have an empty Python file ready for code. Before building agents, you need to verify that the packages you installed can connect to Ollama.
- Copy the following test code into research_assistant.py:
from langchain_ollama import ChatOllama
from langchain_core.messages import HumanMessage
# Test connection to Ollama
llm = ChatOllama(model="qwen3:8b", temperature=0)
response = llm.invoke([HumanMessage(content="Say hello in one sentence.")])
print(response.content)
What does this code do?
- ChatOllama is the LangChain class that sends messages to your local Ollama server at http://127.0.0.1:11434.
- HumanMessage wraps your text in the message format that LangChain expects.
- llm.invoke() sends the message to Ollama and waits for a response.
- Save research_assistant.py with Cmd+S (macOS) or Ctrl+S (Windows).
- Go back to your terminal. Make sure you are in the research-assistant folder and your virtual environment is active ((.venv) is in the prompt).
- Run the test script by executing:
python research_assistant.py
After a few seconds, you should see a greeting from the model printed in your terminal. The response may include <think> tags followed by the actual answer. That is Qwen3's thinking mode in action.
What are the <think> tags?
Qwen3 models default to a "thinking" mode where they reason through a problem before responding. The reasoning appears inside <think> tags. In later steps, your agent system prompts will include instructions to suppress these tags for cleaner output.
Seeing a ConnectionError or ModuleNotFoundError?
- ConnectionError means Ollama is not running. Open the Ollama app from Spotlight search or your Start menu, wait a few seconds, and run the script again.
- ModuleNotFoundError means the packages are not installed in your active environment. Check that (.venv) appears in your terminal prompt before running the script.
- If you see model 'qwen3:8b' not found, go back and run ollama pull qwen3:8b to download the model.
Help me fix my ChatOllama connection.
Your environment is fully set up. Python packages are installed, Ollama is serving qwen3:8b, and your project file is ready. Next up, you will define the shared state that all your agents read from and write to, and create the tools they will use.
Define Your Shared State and Tools
Your environment is ready with all packages installed and Ollama running locally.
Before you build any agents, you need to define how they communicate. In LangGraph, every agent reads from and writes to a shared state object. Without a clear state definition, agents have no way to pass search results, drafts, or decisions to each other.
In this step, get ready to:
- Define the shared state that all agents read and write.
- Create three tools for web search, file saving, and file listing.
- Test your tools to confirm they work before connecting them to agents.
Define the ResearchState TypedDict
Every agent in your system needs access to the same pool of information. LangGraph achieves this with a typed state class that acts as the single source of truth for the entire graph.
- In research_assistant.py, replace everything from your Step 1 test code with the following imports and state definition:
import os
from typing import Annotated, TypedDict
from langchain_community.tools import DuckDuckGoSearchResults
from langchain_core.tools import tool
from langchain_ollama import ChatOllama
from langgraph.graph import add_messages
# --- State Definition ---
class ResearchState(TypedDict):
# add_messages reducer appends new messages instead of overwriting
messages: Annotated[list, add_messages]
# Stores raw search results from the search agent
search_results: str
# Holds the writer agent's draft before approval
draft: str
# Path where approved content gets saved
file_path: str
# Tracks whether the human approved the draft
approved: bool
What does this code do?
- ResearchState is a TypedDict that defines every piece of data your agents can access. Each field is typed so you catch mistakes early.
- The messages field uses Annotated[list, add_messages]. The add_messages reducer tells LangGraph to append new messages to the list instead of replacing it. This preserves the full conversation history.
- The remaining fields (search_results, draft, file_path, approved) are plain values that agents overwrite when they update them.
Why is shared state so important for multi-agent systems?
Think of ResearchState as a shared whiteboard. The search agent writes its findings there. The writer agent reads those findings and posts a draft. The supervisor checks the board to decide who goes next. Without this shared structure, each agent would be isolated with no way to build on another agent's work.
- Save research_assistant.py.
- You won't see a visible change yet. The state definition is used by the agents you'll build in the next steps.
Create the web search tool
Your agents need tools to interact with the outside world. LangGraph uses LangChain's @tool decorator to turn regular Python functions into tools that an LLM can call.
- In research_assistant.py, add the following below the ResearchState class:
# --- Tools ---
OUTPUT_DIR = "output"
@tool
def web_search(query: str) -> str:
"""Search the web for information on a given topic."""
search = DuckDuckGoSearchResults(max_results=3)
results = search.invoke(query)
return results
What does this code do?
- OUTPUT_DIR sets the folder where your agents will save files later.
- The @tool decorator registers web_search as a callable tool. The docstring becomes the tool's description, which the LLM reads to decide when to use it.
- DuckDuckGoSearchResults is a free search integration from langchain-community. No API key required.
- Save research_assistant.py.
- You won't see a change yet. The next two tools complete the set before testing.
Create the file save tool
The writer agent will need a way to persist its summaries to disk. This tool handles creating the output directory and writing content to a file.
- Add the save_file tool below web_search:
@tool
def save_file(content: str, filename: str) -> str:
"""Save content to a file in the output directory."""
os.makedirs(OUTPUT_DIR, exist_ok=True)
file_path = os.path.join(OUTPUT_DIR, filename)
with open(file_path, "w") as f:
f.write(content)
return f"File saved successfully to {file_path}"
What does this code do?
- os.makedirs(OUTPUT_DIR, exist_ok=True) creates the output/ directory if it doesn't exist. The exist_ok=True flag prevents an error if the folder is already there.
- The function returns a confirmation string so the LLM knows the save succeeded.
- Save research_assistant.py.
- You won't see a change yet. One more tool to go before testing.
Create the list files tool
- Add the list_files tool below save_file:
@tool
def list_files() -> str:
"""List all files in the output directory."""
if not os.path.exists(OUTPUT_DIR):
return "No output directory found."
files = os.listdir(OUTPUT_DIR)
if not files:
return "No files found in the output directory."
return "\n".join(files)
What does this code do?
- list_files checks whether the output/ directory exists and returns its contents. If the directory is missing or empty, it returns a clear message instead of crashing.
- This tool takes no parameters. The LLM calls it when a user asks what files have been saved.
- Save research_assistant.py.
Test the tools independently
Before connecting tools to agents, it's worth confirming each one works on its own. You'll invoke them directly in a test block at the bottom of the file.
- Add the following test block at the bottom of research_assistant.py:
if __name__ == "__main__":
# Test web_search tool
print("Testing web_search...")
result = web_search.invoke({"query": "Python programming"})
print(result)
# Test save_file tool
print("\nTesting save_file...")
result = save_file.invoke({"content": "Hello, world!", "filename": "test.txt"})
print(result)
# Test list_files tool
print("\nTesting list_files...")
result = list_files.invoke({})
print(result)
What does this code do?
- Each tool is invoked with .invoke() and a dictionary of arguments matching its function signature. This is how LangChain tools expect to be called programmatically.
- The if __name__ == "__main__": guard ensures this test code only runs when you execute the file directly. It won't interfere when other modules import your tools.
- Save research_assistant.py.
- Run the file by executing this command in your terminal:
python research_assistant.py
You should see three blocks of output. The web search returns snippet results from DuckDuckGo. The save_file test prints a confirmation like File saved successfully to output/test.txt. The list_files test prints test.txt.
Seeing an error when running the test?
- If you see ModuleNotFoundError, make sure your virtual environment is activated. You should see the environment name in your terminal prompt.
- If web_search returns an empty result or a connection error, check that you have an internet connection. DuckDuckGo search requires network access.
- If you see IndentationError, check that each function body is indented with 4 spaces, not tabs.
My tools are raising an error when I run them directly.
Awesome, I've got everything!
Double check you've saved your file before moving on.
I'd like to double check the full code
import os
from typing import Annotated, TypedDict
from langchain_community.tools import DuckDuckGoSearchResults
from langchain_core.tools import tool
from langchain_ollama import ChatOllama
from langgraph.graph import add_messages
# --- State Definition ---
class ResearchState(TypedDict):
# add_messages reducer appends new messages instead of overwriting
messages: Annotated[list, add_messages]
# Stores raw search results from the search agent
search_results: str
# Holds the writer agent's draft before approval
draft: str
# Path where approved content gets saved
file_path: str
# Tracks whether the human approved the draft
approved: bool
# --- Tools ---
OUTPUT_DIR = "output"
@tool
def web_search(query: str) -> str:
"""Search the web for information on a given topic."""
search = DuckDuckGoSearchResults(max_results=3)
results = search.invoke(query)
return results
@tool
def save_file(content: str, filename: str) -> str:
"""Save content to a file in the output directory."""
os.makedirs(OUTPUT_DIR, exist_ok=True)
file_path = os.path.join(OUTPUT_DIR, filename)
with open(file_path, "w") as f:
f.write(content)
return f"File saved successfully to {file_path}"
@tool
def list_files() -> str:
"""List all files in the output directory."""
if not os.path.exists(OUTPUT_DIR):
return "No output directory found."
files = os.listdir(OUTPUT_DIR)
if not files:
return "No files found in the output directory."
return "\n".join(files)
if __name__ == "__main__":
# Test web_search tool
print("Testing web_search...")
result = web_search.invoke({"query": "Python programming"})
print(result)
# Test save_file tool
print("\nTesting save_file...")
result = save_file.invoke({"content": "Hello, world!", "filename": "test.txt"})
print(result)
# Test list_files tool
print("\nTesting list_files...")
result = list_files.invoke({})
print(result)
Your shared state and tools are locked in. Next up, you'll build your first agent node and connect it to the search tool you just tested.
Build Your First Agent Node
Your shared state and tools are ready. Now it's time to build an agent that actually uses them.
In this step, you'll create the search agent, the first of three specialist agents in your system. It takes a user's request, searches the web using your web_search tool, and returns a summary of what it finds. Along the way, you'll also extract a reusable helper function so you don't repeat setup code for every agent.
In this step, get ready to:
- Create a reusable helper function for initializing agents.
- Build the search agent node function.
- Test the search agent in isolation with a minimal graph.
Create a reusable agent helper
Every agent in your system needs the same basic setup: a ChatOllama instance, a system prompt, and optional tools. Instead of repeating this in every agent function, you'll extract it into a helper.
- In research_assistant.py, find the comment # --- Tools --- and scroll past all of your tool functions.
- Add the following section comment and helper function below your tools:
# --- Agent Helper ---
def create_agent(system_prompt: str, tools: list | None = None):
"""Create a ChatOllama agent with optional tools."""
# Initialize the LLM with qwen3:8b
llm = ChatOllama(model="qwen3:8b", temperature=0)
# Bind tools to the LLM if any are provided
if tools:
llm = llm.bind_tools(tools)
# Return both the LLM and prompt for the agent to use
return llm, system_prompt
What does this code do?
- create_agent takes a system prompt string and an optional list of tools.
- It creates a ChatOllama instance pointing at qwen3:8b with temperature=0 for deterministic output.
- bind_tools() attaches the tools to the LLM so the model knows what functions it can call.
- It returns both the configured LLM and the prompt as a tuple. Each agent will use these to set up its conversation.
- Save research_assistant.py.
You won't see a change yet. The next chunk uses this helper to build the search agent.
Build the search agent node
A node in LangGraph is a Python function that receives the current graph state and returns updates. Your search agent node will use the web_search tool to find information, summarize the results, and route back to the supervisor using the Command API.
Before writing the function, you need to add a few new imports to the top of your file.
- In research_assistant.py, scroll to the top of the file and find your existing from typing import line.
- Find this line:
from typing import Annotated, TypedDict
- Add Literal to the import so it looks like this:
from typing import Annotated, Literal, TypedDict
- Add these two new import lines below your existing imports at the top of the file:
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langgraph.types import Command, interrupt
What are these imports for?
- Literal lets you declare the exact node names the agent can route to. LangGraph reads this type annotation at compile time to build the graph's edge set.
- AIMessage, HumanMessage, and SystemMessage are LangChain's message types for structuring conversations with the LLM.
- Command is the LangGraph routing primitive. Instead of defining static edges, each node uses Command(goto="node_name") to decide where to go next at runtime.
- interrupt pauses graph execution for human input. You'll use it in a later step, but importing it now keeps the imports clean.
Now you're ready for the search agent itself.
- In research_assistant.py, add the following section comment and function below your create_agent helper:
# --- Agent Nodes ---
def search_agent(state: ResearchState) -> Command[Literal["supervisor"]]:
"""Web search specialist agent."""
# Set up the LLM with a research-focused system prompt and the web_search tool
llm, prompt = create_agent(
system_prompt=(
"You are a web research specialist. Use the web_search tool to find "
"relevant information based on the user's request. Summarize what you "
"find clearly and concisely. Do not use /no_think or thinking tags."
),
tools=[web_search],
)
# Build the message list with the system prompt and the shared conversation
messages = [SystemMessage(content=prompt)] + state["messages"]
response = llm.invoke(messages)
# Execute any tool calls the LLM made
search_results = ""
if response.tool_calls:
for tc in response.tool_calls:
if tc["name"] == "web_search":
result = web_search.invoke(tc["args"])
search_results = result
# Summarize the raw search results with a second LLM call
summary_llm = ChatOllama(model="qwen3:8b", temperature=0)
summary_messages = [
SystemMessage(
content="Summarize these search results concisely. Do not use /no_think or thinking tags."
),
HumanMessage(content=f"Search results:\n{search_results}"),
]
summary = summary_llm.invoke(summary_messages)
# Route back to the supervisor with the results
return Command(
goto="supervisor",
update={
"messages": [AIMessage(content=f"[Search Agent] {summary.content}")],
"search_results": search_results,
},
)
What does this code do?
This function is a LangGraph node. It follows a three-phase pattern that every agent node in your project will use.
- Phase 1: Setup. It calls create_agent with a focused system prompt and the web_search tool, then builds a message list from the shared state.
- Phase 2: Execute. The LLM decides whether to call web_search. If it does, the function manually invokes the tool and captures the raw results. A second LLM call summarizes those results into something readable.
- Phase 3: Route. It returns Command(goto="supervisor") to send control back to the supervisor. The update parameter merges the search results and a summary message into the shared state before the supervisor runs.
Why two LLM calls instead of one?
The first LLM call decides whether to search and what query to use. The raw results from DuckDuckGo are messy (URLs, snippets, metadata). The second call distills those raw results into a clean summary that other agents can actually use.
Why does the return type say Command[Literal["supervisor"]]?
LangGraph reads this type annotation at compile time to figure out the graph's edges. The Literal["supervisor"] tells LangGraph that this node can route to the supervisor node. The actual routing decision happens at runtime through Command(goto=...), but the type hint gives LangGraph the information it needs to validate the graph structure.
- Save research_assistant.py.
You won't see a visible change yet. The next section tests this agent to confirm it works.
Test the search agent in isolation
Before wiring up the full multi-agent system, you should verify the search agent works on its own. You'll create a minimal StateGraph with just the search agent node, compile it, and invoke it with a test query.
The search agent routes to a supervisor node that doesn't exist yet. For now, you'll add a dummy supervisor that just passes state through so the graph can complete.
- In research_assistant.py, find your if __name__ == "__main__": block at the bottom of the file.
- Replace the existing test code with the following:
if __name__ == "__main__":
# Build a minimal graph with just the search agent
builder = StateGraph(ResearchState)
builder.add_node("search_agent", search_agent)
# Dummy supervisor node so the search agent has somewhere to route to
builder.add_node("supervisor", lambda state: state)
builder.add_edge(START, "search_agent")
builder.add_edge("supervisor", END)
graph = builder.compile()
# Test with a sample query
result = graph.invoke({
"messages": [HumanMessage(content="What are the latest trends in quantum computing?")],
"search_results": "",
"draft": "",
"file_path": "",
"approved": False,
})
# Print the agent's output
for msg in result["messages"]:
if hasattr(msg, "content") and msg.content:
print(msg.content)
What does this test do?
- It creates a minimal StateGraph with two nodes: the real search_agent and a dummy supervisor that does nothing.
- The graph starts at search_agent, which uses Command(goto="supervisor") to route to the dummy node. The dummy supervisor then exits via the END edge.
- The initial state provides all the fields your ResearchState TypedDict requires, with empty defaults for everything except the test message.
- Save research_assistant.py.
- Run the test by executing this command in your terminal:
python research_assistant.py
This takes 30-60 seconds because the search agent makes two LLM calls plus a DuckDuckGo search. You should see a message starting with [Search Agent] followed by a summary of search results about quantum computing.
Not seeing any output?
- Make sure Ollama is running. Open a separate terminal and run ollama list to confirm qwen3:8b is available.
- If you see a connection error, start Ollama with ollama serve in a separate terminal window.
- If the script hangs for more than 2 minutes, the LLM may be struggling with the tool call. Press Ctrl+C to stop it and try running again.
- Check that your virtual environment is activated. You should see the environment name in your terminal prompt.
My search agent test isn't producing results. Can you help me debug?
What if the output has <think> tags?
Qwen3 models default to a "thinking" mode that wraps reasoning in <think> tags. The system prompts include "Do not use /no_think or thinking tags" to suppress this. If you still see thinking tags in the output, the summary is still usable. The tags will be cleaned up in the final version.
Awesome, I've got everything!
Double check you've saved research_assistant.py before moving on.
I'd like to double check the full code
Here is the full research_assistant.py file up to this point, excluding the temporary test block at the bottom.
import os
from typing import Annotated, Literal, TypedDict
from langchain_community.tools import DuckDuckGoSearchResults
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_core.tools import tool
from langchain_ollama import ChatOllama
from langgraph.graph import END, START, StateGraph, add_messages
from langgraph.types import Command, interrupt
# --- State Definition ---
class ResearchState(TypedDict):
messages: Annotated[list, add_messages]
search_results: str
draft: str
file_path: str
approved: bool
# --- Tools ---
OUTPUT_DIR = "output"
@tool
def web_search(query: str) -> str:
"""Search the web for information on a given topic."""
search = DuckDuckGoSearchResults(max_results=3)
results = search.invoke(query)
return results
@tool
def save_file(content: str, filename: str) -> str:
"""Save content to a file in the output directory."""
os.makedirs(OUTPUT_DIR, exist_ok=True)
file_path = os.path.join(OUTPUT_DIR, filename)
with open(file_path, "w") as f:
f.write(content)
return f"File saved successfully to {file_path}"
@tool
def list_files() -> str:
"""List all files in the output directory."""
if not os.path.exists(OUTPUT_DIR):
return "No output directory found."
files = os.listdir(OUTPUT_DIR)
if not files:
return "No files found in the output directory."
return "\n".join(files)
# --- Agent Helper ---
def create_agent(system_prompt: str, tools: list | None = None):
"""Create a ChatOllama agent with optional tools."""
llm = ChatOllama(model="qwen3:8b", temperature=0)
if tools:
llm = llm.bind_tools(tools)
return llm, system_prompt
# --- Agent Nodes ---
def search_agent(state: ResearchState) -> Command[Literal["supervisor"]]:
"""Web search specialist agent."""
llm, prompt = create_agent(
system_prompt=(
"You are a web research specialist. Use the web_search tool to find "
"relevant information based on the user's request. Summarize what you "
"find clearly and concisely. Do not use /no_think or thinking tags."
),
tools=[web_search],
)
messages = [SystemMessage(content=prompt)] + state["messages"]
response = llm.invoke(messages)
search_results = ""
if response.tool_calls:
for tc in response.tool_calls:
if tc["name"] == "web_search":
result = web_search.invoke(tc["args"])
search_results = result
summary_llm = ChatOllama(model="qwen3:8b", temperature=0)
summary_messages = [
SystemMessage(
content="Summarize these search results concisely. Do not use /no_think or thinking tags."
),
HumanMessage(content=f"Search results:\n{search_results}"),
]
summary = summary_llm.invoke(summary_messages)
return Command(
goto="supervisor",
update={
"messages": [AIMessage(content=f"[Search Agent] {summary.content}")],
"search_results": search_results,
},
)
Your first agent is working. Next up, you'll build the writer and Q&A agents so your system has a full team of specialists to coordinate.
Build the Writer and Q&A Agent Nodes
Your search agent can pull live results from the web and route them back to the supervisor. Now you need two more specialists to complete your agent team.
The writer agent takes the search results gathered by the search agent and turns them into a polished summary saved to a file. The Q&A agent handles follow-up questions by synthesizing everything in the conversation history. Together, these three agents cover the full research workflow: find, write, and answer.
In this step, get ready to:
- Create the writer agent that drafts summaries and saves them to disk.
- Create the Q&A agent that answers questions using conversation context.
- Test both agents with mock state data to confirm they work.
Create the writer agent node
The writer agent receives the shared state (which contains search results from the search agent) and uses the LLM to draft a summary. Instead of binding save_file as a tool for the LLM to decide when to call, the writer calls save_file directly after generating the draft. This keeps the file-saving step predictable.
- In research_assistant.py, add the writer_agent function below the search_agent function by copying this code:
def writer_agent(state: ResearchState) -> Command[Literal["supervisor"]]:
"""Writer agent that drafts summaries and saves them."""
# Create the LLM without tools - we call save_file directly
llm, prompt = create_agent(
system_prompt=(
"You are a professional writer. Based on the research provided in the "
"conversation, draft a clear, well-structured summary. Keep it concise "
"but informative. Do not use /no_think or thinking tags."
),
)
# Build the message list with system prompt and conversation history
messages = [SystemMessage(content=prompt)] + state["messages"]
response = llm.invoke(messages)
draft = response.content
# Save the draft directly to a file
filename = "research_summary.md"
save_result = save_file.invoke({"content": draft, "filename": filename})
# Route back to the supervisor with updated state
return Command(
goto="supervisor",
update={
"messages": [
AIMessage(
content=f"[Writer Agent] Draft saved. {save_result}"
)
],
"draft": draft,
"file_path": os.path.join(OUTPUT_DIR, filename),
"approved": True,
},
)
What does this code do?
- create_agent() initializes ChatOllama with a system prompt that tells the model to act as a professional writer. No tools are bound because the agent handles file saving in code, not via LLM tool calling.
- The agent prepends the system prompt to the conversation's message history, then calls llm.invoke() to generate a draft summary from the search results already in the state.
- save_file.invoke() writes the draft to research_summary.md inside the output/ directory.
- Command(goto="supervisor") routes control back to the supervisor after the work is done. The update dictionary merges the draft, file path, and approval status into the shared state.
Why call save_file directly instead of binding it as a tool?
The search agent binds web_search as a tool because the LLM needs to decide what query to search for. The writer's file save is different. Every time the writer runs, it should save. There's no decision for the LLM to make, so calling save_file.invoke() directly is simpler and more reliable.
- Save research_assistant.py.
You won't see a change yet. The Q&A agent is next, and then you'll test both together.
Create the Q&A agent node
The Q&A agent is the simplest of the three. It has no tools at all. It reads the full conversation history and answers follow-up questions using the context that the search and writer agents have already gathered.
- Add the qa_agent function below the writer_agent function by copying this code:
def qa_agent(state: ResearchState) -> Command[Literal["supervisor"]]:
"""Q&A agent that answers questions using conversation context."""
# Create the LLM without tools - Q&A uses only conversation context
llm, prompt = create_agent(
system_prompt=(
"You are a knowledgeable assistant. Answer the user's question based on "
"the information available in the conversation history. Be accurate and "
"helpful. Do not use /no_think or thinking tags."
),
)
# Build messages from system prompt and full conversation history
messages = [SystemMessage(content=prompt)] + state["messages"]
response = llm.invoke(messages)
# Route back to the supervisor with the answer
return Command(
goto="supervisor",
update={
"messages": [AIMessage(content=f"[Q&A Agent] {response.content}")],
},
)
What does this code do?
- The Q&A agent creates a ChatOllama instance with no tools. It only needs the LLM's language understanding, not any external actions.
- It passes the entire message history (including outputs from the search and writer agents) as context. This is how the Q&A agent knows what was researched and written, even though it never called any tools itself.
- Like the other agents, it returns Command(goto="supervisor") to hand control back to the supervisor. The only state update is the answer message.
How does the Q&A agent know what was researched?
Every agent writes its output to the shared messages list in ResearchState. When the search agent finishes, its summary becomes part of the message history. When the Q&A agent runs, it receives that full history as context. This is the power of shared typed state in LangGraph. Agents don't need to call each other directly. They communicate through the state.
- Save research_assistant.py.
Both agent functions are in place. Time to verify they actually work.
Test both agents with mock state data
Before wiring the agents into the full graph, you should test them in isolation. You can call each agent function directly with mock state data to confirm the writer produces a saved file and the Q&A agent generates a coherent answer.
- Replace the existing if __name__ == "__main__" block at the bottom of research_assistant.py with this test code:
if __name__ == "__main__":
# Test writer agent with mock state containing search context
print("Testing writer agent...")
mock_state = {
"messages": [
HumanMessage(
content="Research results: AI trends include large language models, "
"multi-agent systems, and retrieval-augmented generation. "
"Please write a summary and save it."
)
],
"search_results": "AI trends: LLMs, multi-agent systems, RAG",
"draft": "",
"file_path": "",
"approved": False,
}
writer_result = writer_agent(mock_state)
print(f"Writer result: {writer_result}")
# Test Q&A agent with mock conversation history
print("\nTesting Q&A agent...")
qa_state = {
"messages": [
HumanMessage(content="What are the main AI trends you found?"),
AIMessage(
content="[Search Agent] Key AI trends include LLMs, multi-agent systems, and RAG."
),
HumanMessage(content="Can you explain what RAG means?"),
],
"search_results": "AI trends: LLMs, multi-agent systems, RAG",
"draft": "",
"file_path": "",
"approved": False,
}
qa_result = qa_agent(qa_state)
print(f"Q&A result: {qa_result}")
What does this test code do?
- The writer test creates a mock_state dictionary that mimics what the shared state looks like after the search agent has run. The message contains fake search results so the writer has material to summarize.
- The Q&A test creates a qa_state with a multi-turn conversation history. This simulates what the state looks like after the search agent has already answered a question and the user is asking a follow-up.
- Both tests call the agent function directly, bypassing the graph. Each returns a Command object that you can inspect in the terminal output.
- Save research_assistant.py.
- Run the test by executing this command in your terminal:
python research_assistant.py
The writer test takes 30-60 seconds as Ollama generates the draft. You should see a Command object printed for each agent. The writer's output includes a message like [Writer Agent] Draft saved. File saved successfully to output/research_summary.md.
- Confirm the output file was created by running:
ls output/
You should see research_summary.md listed in the output.
Not seeing the output file or getting an error?
- If you see Connection refused, make sure Ollama is running. Open a separate terminal and run ollama serve.
- If the writer runs but no file appears, check that the OUTPUT_DIR variable and save_file function from Step 2 are still in your file above the new agent functions.
- If the Q&A agent's response seems incoherent or includes <think> tags, verify the system prompt includes "Do not use /no_think or thinking tags." exactly as shown in the code above.
- My agents are throwing an unexpected error.
Awesome, I've got everything!
Double check you've saved research_assistant.py and both agent tests produced output.
I'd like to double check the full code
Here is the full research_assistant.py file as it should look at the end of this step.
import os
from typing import Annotated, Literal, TypedDict
from langchain_community.tools import DuckDuckGoSearchResults
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_core.tools import tool
from langchain_ollama import ChatOllama
from langgraph.graph import END, START, StateGraph, add_messages
from langgraph.types import Command
# --- State Definition ---
class ResearchState(TypedDict):
messages: Annotated[list, add_messages]
search_results: str
draft: str
file_path: str
approved: bool
# --- Tools ---
OUTPUT_DIR = "output"
@tool
def web_search(query: str) -> str:
"""Search the web for information on a given topic."""
search = DuckDuckGoSearchResults(max_results=3)
results = search.invoke(query)
return results
@tool
def save_file(content: str, filename: str) -> str:
"""Save content to a file in the output directory."""
os.makedirs(OUTPUT_DIR, exist_ok=True)
file_path = os.path.join(OUTPUT_DIR, filename)
with open(file_path, "w") as f:
f.write(content)
return f"File saved successfully to {file_path}"
@tool
def list_files() -> str:
"""List all files in the output directory."""
if not os.path.exists(OUTPUT_DIR):
return "No output directory found."
files = os.listdir(OUTPUT_DIR)
if not files:
return "No files found in the output directory."
return "\n".join(files)
# --- Agent Helper ---
def create_agent(system_prompt: str, tools: list | None = None):
"""Create a ChatOllama agent with optional tools."""
llm = ChatOllama(model="qwen3:8b", temperature=0)
if tools:
llm = llm.bind_tools(tools)
return llm, system_prompt
# --- Agent Nodes ---
def search_agent(state: ResearchState) -> Command[Literal["supervisor"]]:
"""Web search specialist agent."""
llm, prompt = create_agent(
system_prompt=(
"You are a web research specialist. Use the web_search tool to find "
"relevant information based on the user's request. Summarize what you "
"find clearly and concisely. Do not use /no_think or thinking tags."
),
tools=[web_search],
)
messages = [SystemMessage(content=prompt)] + state["messages"]
response = llm.invoke(messages)
search_results = ""
if response.tool_calls:
for tc in response.tool_calls:
if tc["name"] == "web_search":
result = web_search.invoke(tc["args"])
search_results = result
summary_llm = ChatOllama(model="qwen3:8b", temperature=0)
summary_messages = [
SystemMessage(
content="Summarize these search results concisely. Do not use /no_think or thinking tags."
),
HumanMessage(content=f"Search results:\n{search_results}"),
]
summary = summary_llm.invoke(summary_messages)
return Command(
goto="supervisor",
update={
"messages": [AIMessage(content=f"[Search Agent] {summary.content}")],
"search_results": search_results,
},
)
def writer_agent(state: ResearchState) -> Command[Literal["supervisor"]]:
"""Writer agent that drafts summaries and saves them."""
# Create the LLM without tools - we call save_file directly
llm, prompt = create_agent(
system_prompt=(
"You are a professional writer. Based on the research provided in the "
"conversation, draft a clear, well-structured summary. Keep it concise "
"but informative. Do not use /no_think or thinking tags."
),
)
# Build the message list with system prompt and conversation history
messages = [SystemMessage(content=prompt)] + state["messages"]
response = llm.invoke(messages)
draft = response.content
# Save the draft directly to a file
filename = "research_summary.md"
save_result = save_file.invoke({"content": draft, "filename": filename})
# Route back to the supervisor with updated state
return Command(
goto="supervisor",
update={
"messages": [
AIMessage(
content=f"[Writer Agent] Draft saved. {save_result}"
)
],
"draft": draft,
"file_path": os.path.join(OUTPUT_DIR, filename),
"approved": True,
},
)
def qa_agent(state: ResearchState) -> Command[Literal["supervisor"]]:
"""Q&A agent that answers questions using conversation context."""
# Create the LLM without tools - Q&A uses only conversation context
llm, prompt = create_agent(
system_prompt=(
"You are a knowledgeable assistant. Answer the user's question based on "
"the information available in the conversation history. Be accurate and "
"helpful. Do not use /no_think or thinking tags."
),
)
# Build messages from system prompt and full conversation history
messages = [SystemMessage(content=prompt)] + state["messages"]
response = llm.invoke(messages)
# Route back to the supervisor with the answer
return Command(
goto="supervisor",
update={
"messages": [AIMessage(content=f"[Q&A Agent] {response.content}")],
},
)
if __name__ == "__main__":
# Test writer agent with mock state containing search context
print("Testing writer agent...")
mock_state = {
"messages": [
HumanMessage(
content="Research results: AI trends include large language models, "
"multi-agent systems, and retrieval-augmented generation. "
"Please write a summary and save it."
)
],
"search_results": "AI trends: LLMs, multi-agent systems, RAG",
"draft": "",
"file_path": "",
"approved": False,
}
writer_result = writer_agent(mock_state)
print(f"Writer result: {writer_result}")
# Test Q&A agent with mock conversation history
print("\nTesting Q&A agent...")
qa_state = {
"messages": [
HumanMessage(content="What are the main AI trends you found?"),
AIMessage(
content="[Search Agent] Key AI trends include LLMs, multi-agent systems, and RAG."
),
HumanMessage(content="Can you explain what RAG means?"),
],
"search_results": "AI trends: LLMs, multi-agent systems, RAG",
"draft": "",
"file_path": "",
"approved": False,
}
qa_result = qa_agent(qa_state)
print(f"Q&A result: {qa_result}")
You now have three working agents: a searcher, a writer, and a Q&A specialist. Next up, you'll build the supervisor that decides which agent to call and wire everything into a single graph.
Build the Supervisor and Wire the Graph
Your three specialist agents are built and tested individually. Each one knows how to do its job, but none of them know about each other.
In this step, you'll create the supervisor, the central coordinator that reads every request and decides which agent should handle it. You'll wire all four nodes into a single StateGraph and watch the full system route tasks from one agent to the next.
In this step, get ready to:
- Create the supervisor node that classifies intent and routes to the right agent.
- Wire all agent nodes into a single StateGraph with dynamic routing.
- Test the full multi-agent flow end-to-end.
Create the supervisor node
The supervisor is the brain of your multi-agent system. Every user message goes to the supervisor first. It reads the conversation, decides which specialist should handle the next step, and routes the task dynamically.
Instead of using tool calling for routing, the supervisor uses a simpler approach. It asks the LLM to output just the name of the target agent. This text classification is more reliable for routing decisions than structured tool calls.
- In research_assistant.py, find the comment # --- Agent Nodes ---. Add the following supervisor function above that comment, under a new # --- Supervisor Node --- section:
# --- Supervisor Node ---
def supervisor(state: ResearchState) -> Command[Literal["search_agent", "writer_agent", "qa_agent", "__end__"]]:
"""Supervisor that routes tasks to the appropriate specialist agent."""
llm = ChatOllama(model="qwen3:8b", temperature=0)
# Prompt that asks the LLM to pick an agent or finish
route_prompt = (
"You are a supervisor managing a team of specialist agents. "
"Based on the conversation, decide which agent should handle the next step.\n\n"
"Available agents:\n"
"- search_agent: For finding information on the web\n"
"- writer_agent: For drafting and saving summaries or documents\n"
"- qa_agent: For answering questions about gathered information\n"
"- FINISH: When the task is complete and no more work is needed\n\n"
"Respond with ONLY one of: search_agent, writer_agent, qa_agent, FINISH\n"
"Do not explain your reasoning. Do not use /no_think or thinking tags. "
"Just output the agent name or FINISH."
)
messages = [SystemMessage(content=route_prompt)] + state["messages"]
response = llm.invoke(messages)
route = response.content.strip().lower()
# Clean up the response to extract the route
for agent_name in ["search_agent", "writer_agent", "qa_agent", "finish"]:
if agent_name in route:
route = agent_name
break
# Route to END when the task is complete
if route == "finish":
return Command(
goto=END,
update={
"messages": [
AIMessage(
content="[Supervisor] Task complete. All agents have finished their work."
)
],
},
)
# Default to qa_agent if routing is unclear
if route not in ["search_agent", "writer_agent", "qa_agent"]:
route = "qa_agent"
# Route to the chosen agent
return Command(
goto=route,
update={
"messages": [
AIMessage(content=f"[Supervisor] Routing to {route}...")
],
},
)
What does this code do?
- The type annotation Command[Literal["search_agent", "writer_agent", "qa_agent", "__end__"]] tells LangGraph which nodes this function can route to at compile time. This lets the graph validate edges before you run anything.
- The route_prompt constrains the LLM to output only an agent name or FINISH. The cleanup loop handles cases where the LLM adds extra text around its answer.
- Command(goto=route) makes routing dynamic at runtime. The supervisor picks the target node based on the conversation, not a hardcoded edge. The update parameter merges a status message into the shared state before the target node runs.
- Command(goto=END) terminates the graph when there is no more work to do.
Why Command instead of conditional edges?
LangGraph also supports add_conditional_edges, where you define a routing function separately from the node. The Command API bundles the routing decision and the state update into a single return value. This keeps all the supervisor's logic inside one function instead of splitting it across a node and a separate router.
- Save research_assistant.py.
You won't see a change yet. The supervisor needs to be wired into a graph before you can test it.
Wire all nodes into a single StateGraph
With all four nodes built (supervisor, search, writer, Q&A), it's time to connect them into a single graph. The structure is simple. Every request enters through the supervisor. Each worker routes back to the supervisor after completing its task.
- At the bottom of research_assistant.py, add the following build_graph function above your existing if __name__ == "__main__" block:
# --- Build the Graph ---
def build_graph():
"""Build and compile the multi-agent research assistant graph."""
builder = StateGraph(ResearchState)
# Add nodes
builder.add_node("supervisor", supervisor)
builder.add_node("search_agent", search_agent)
builder.add_node("writer_agent", writer_agent)
builder.add_node("qa_agent", qa_agent)
# Entry point
builder.add_edge(START, "supervisor")
# Compile the graph
graph = builder.compile()
return graph
What does this code do?
- StateGraph(ResearchState) creates a graph where every node reads from and writes to the same ResearchState you defined earlier.
- add_node registers each agent function under a string name. This name is what Command(goto=...) uses to route between nodes.
- add_edge(START, "supervisor") is the only static edge in the graph. It sends every new request to the supervisor first. All other routing happens dynamically through Command returns.
- builder.compile() validates the graph structure and returns a runnable object you can invoke.
Why are there no edges between workers?
You might expect to see edges like add_edge("search_agent", "supervisor"). You don't need them because each worker's Command(goto="supervisor") return value already handles that routing. LangGraph reads the type annotation on each node function to discover valid routes at compile time.
- Save research_assistant.py.
The graph is ready to compile. Next, you'll replace your test code to run the full system end-to-end.
Compile and test the full graph
Time to see all four agents work together. You'll send a search request and watch the supervisor route it through the search agent, then the writer agent, and finally back to the supervisor to finish.
- In research_assistant.py, replace your existing if __name__ == "__main__" block with this test code:
if __name__ == "__main__":
# Build the graph
graph = build_graph()
# Test with a search-and-summarize request
print("=== Testing Full Graph ===")
result = graph.invoke(
{
"messages": [HumanMessage(content="Research the latest trends in quantum computing and save a summary.")],
"search_results": "",
"draft": "",
"file_path": "",
"approved": False,
}
)
# Print all agent messages to trace the routing flow
print("\n=== Agent Messages ===")
for msg in result["messages"]:
if isinstance(msg, AIMessage) and msg.content:
print(f"\n{msg.content}")
What does this test do?
This sends a request that requires two agents. The supervisor should route to search_agent first (to find information), then to writer_agent (to save a summary), then call FINISH. The message trace at the end lets you verify the routing flow.
- Save research_assistant.py.
- Run the test by executing this command in your terminal:
python research_assistant.py
What should I see?
The graph takes 30-90 seconds to complete because each agent call runs the local LLM. You should see output like this:
=== Testing Full Graph ===
=== Agent Messages ===
[Supervisor] Routing to search_agent...
[Search Agent] Here is a summary of the latest trends...
[Supervisor] Routing to writer_agent...
[Writer Agent] Draft saved. File saved successfully to output/research_summary.md
[Supervisor] Task complete. All agents have finished their work.
The exact wording depends on the LLM's responses. The key thing to verify is that the messages show the supervisor routing between agents in a logical order.
- Confirm the routing flow by checking that the output includes [Supervisor] Routing to search_agent... and [Supervisor] Routing to writer_agent... messages.
- Check that a research_summary.md file was created in your output/ directory by running:
ls output/
You should see research_summary.md listed in the output.
Graph not finishing or stuck in a loop?
- If the supervisor keeps routing to the same agent repeatedly, the LLM may not be outputting clean agent names. Check that your route_prompt includes the line Do not use /no_think or thinking tags. Qwen3 defaults to thinking mode and may wrap its answer in extra tags.
- If you see a GraphRecursionError, the supervisor is looping more than 25 times (the default limit). This usually means the cleanup loop in your supervisor isn't matching any agent name. Double-check that the four strings in your for agent_name in [...] loop match exactly: search_agent, writer_agent, qa_agent, finish.
- Make sure Ollama is still running. If your Mac went to sleep, the Ollama server may have stopped. Run ollama list in a new terminal to confirm.
Still stuck? Ask for help.
Awesome, I've got everything!
Great. Double-check you've saved research_assistant.py before moving on.
I'd like to double check the full code
Here is the full research_assistant.py as it should look at the end of this step.
import os
from typing import Annotated, Literal, TypedDict
from langchain_community.tools import DuckDuckGoSearchResults
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_core.tools import tool
from langchain_ollama import ChatOllama
from langgraph.graph import END, START, StateGraph, add_messages
from langgraph.types import Command
# --- State Definition ---
class ResearchState(TypedDict):
messages: Annotated[list, add_messages]
search_results: str
draft: str
file_path: str
approved: bool
# --- Tools ---
OUTPUT_DIR = "output"
@tool
def web_search(query: str) -> str:
"""Search the web for information on a given topic."""
search = DuckDuckGoSearchResults(max_results=3)
results = search.invoke(query)
return results
@tool
def save_file(content: str, filename: str) -> str:
"""Save content to a file in the output directory."""
os.makedirs(OUTPUT_DIR, exist_ok=True)
file_path = os.path.join(OUTPUT_DIR, filename)
with open(file_path, "w") as f:
f.write(content)
return f"File saved successfully to {file_path}"
@tool
def list_files() -> str:
"""List all files in the output directory."""
if not os.path.exists(OUTPUT_DIR):
return "No output directory found."
files = os.listdir(OUTPUT_DIR)
if not files:
return "No files found in the output directory."
return "\n".join(files)
# --- Agent Helper ---
def create_agent(system_prompt: str, tools: list | None = None):
"""Create a ChatOllama agent with optional tools."""
llm = ChatOllama(model="qwen3:8b", temperature=0)
if tools:
llm = llm.bind_tools(tools)
return llm, system_prompt
# --- Supervisor Node ---
def supervisor(state: ResearchState) -> Command[Literal["search_agent", "writer_agent", "qa_agent", "__end__"]]:
"""Supervisor that routes tasks to the appropriate specialist agent."""
llm = ChatOllama(model="qwen3:8b", temperature=0)
# Prompt that asks the LLM to pick an agent or finish
route_prompt = (
"You are a supervisor managing a team of specialist agents. "
"Based on the conversation, decide which agent should handle the next step.\n\n"
"Available agents:\n"
"- search_agent: For finding information on the web\n"
"- writer_agent: For drafting and saving summaries or documents\n"
"- qa_agent: For answering questions about gathered information\n"
"- FINISH: When the task is complete and no more work is needed\n\n"
"Respond with ONLY one of: search_agent, writer_agent, qa_agent, FINISH\n"
"Do not explain your reasoning. Do not use /no_think or thinking tags. "
"Just output the agent name or FINISH."
)
messages = [SystemMessage(content=route_prompt)] + state["messages"]
response = llm.invoke(messages)
route = response.content.strip().lower()
# Clean up the response to extract the route
for agent_name in ["search_agent", "writer_agent", "qa_agent", "finish"]:
if agent_name in route:
route = agent_name
break
# Route to END when the task is complete
if route == "finish":
return Command(
goto=END,
update={
"messages": [
AIMessage(
content="[Supervisor] Task complete. All agents have finished their work."
)
],
},
)
# Default to qa_agent if routing is unclear
if route not in ["search_agent", "writer_agent", "qa_agent"]:
route = "qa_agent"
# Route to the chosen agent
return Command(
goto=route,
update={
"messages": [
AIMessage(content=f"[Supervisor] Routing to {route}...")
],
},
)
# --- Agent Nodes ---
def search_agent(state: ResearchState) -> Command[Literal["supervisor"]]:
"""Web search specialist agent."""
llm, prompt = create_agent(
system_prompt=(
"You are a web research specialist. Use the web_search tool to find "
"relevant information based on the user's request. Summarize what you "
"find clearly and concisely. Do not use /no_think or thinking tags."
),
tools=[web_search],
)
messages = [SystemMessage(content=prompt)] + state["messages"]
response = llm.invoke(messages)
search_results = ""
if response.tool_calls:
for tc in response.tool_calls:
if tc["name"] == "web_search":
result = web_search.invoke(tc["args"])
search_results = result
summary_llm = ChatOllama(model="qwen3:8b", temperature=0)
summary_messages = [
SystemMessage(
content="Summarize these search results concisely. Do not use /no_think or thinking tags."
),
HumanMessage(content=f"Search results:\n{search_results}"),
]
summary = summary_llm.invoke(summary_messages)
return Command(
goto="supervisor",
update={
"messages": [AIMessage(content=f"[Search Agent] {summary.content}")],
"search_results": search_results,
},
)
def writer_agent(state: ResearchState) -> Command[Literal["supervisor"]]:
"""Writer agent that drafts summaries and saves them."""
llm, prompt = create_agent(
system_prompt=(
"You are a professional writer. Based on the research provided in the "
"conversation, draft a clear, well-structured summary. Keep it concise "
"but informative. Do not use /no_think or thinking tags."
),
)
messages = [SystemMessage(content=prompt)] + state["messages"]
response = llm.invoke(messages)
draft = response.content
# Save the draft to a file
filename = "research_summary.md"
save_result = save_file.invoke({"content": draft, "filename": filename})
return Command(
goto="supervisor",
update={
"messages": [
AIMessage(
content=f"[Writer Agent] Draft saved. {save_result}"
)
],
"draft": draft,
"file_path": os.path.join(OUTPUT_DIR, filename),
"approved": True,
},
)
def qa_agent(state: ResearchState) -> Command[Literal["supervisor"]]:
"""Q&A agent that answers questions using conversation context."""
llm, prompt = create_agent(
system_prompt=(
"You are a knowledgeable assistant. Answer the user's question based on "
"the information available in the conversation history. Be accurate and "
"helpful. Do not use /no_think or thinking tags."
),
)
messages = [SystemMessage(content=prompt)] + state["messages"]
response = llm.invoke(messages)
return Command(
goto="supervisor",
update={
"messages": [AIMessage(content=f"[Q&A Agent] {response.content}")],
},
)
# --- Build the Graph ---
def build_graph():
"""Build and compile the multi-agent research assistant graph."""
builder = StateGraph(ResearchState)
# Add nodes
builder.add_node("supervisor", supervisor)
builder.add_node("search_agent", search_agent)
builder.add_node("writer_agent", writer_agent)
builder.add_node("qa_agent", qa_agent)
# Entry point
builder.add_edge(START, "supervisor")
# Compile the graph
graph = builder.compile()
return graph
if __name__ == "__main__":
# Build the graph
graph = build_graph()
# Test with a search-and-summarize request
print("=== Testing Full Graph ===")
result = graph.invoke(
{
"messages": [HumanMessage(content="Research the latest trends in quantum computing and save a summary.")],
"search_results": "",
"draft": "",
"file_path": "",
"approved": False,
}
)
# Print all agent messages to trace the routing flow
print("\n=== Agent Messages ===")
for msg in result["messages"]:
if isinstance(msg, AIMessage) and msg.content:
print(f"\n{msg.content}")
Your multi-agent system is working end-to-end. The supervisor reads each request, picks the right agent, and finishes when the work is done. Next up, you'll add human-in-the-loop approval so the writer pauses for your review before saving files to disk.
Add Human-in-the-Loop Approval
Your supervisor is now routing tasks to all three agents, and the writer saves files automatically.
That's a problem. You don't want an AI writing files to disk without asking first. In this step, you'll add a human-in-the-loop approval step that pauses the graph before the writer saves anything. The graph freezes mid-execution, shows you a preview of the draft, and waits for your yes or no.
In this step, get ready to:
- Add a checkpointer so the graph can pause and resume mid-execution.
- Modify the writer agent to call interrupt() before saving files.
- Test the full approval and rejection flow end-to-end.
Add a checkpointer for interrupt/resume support
For a graph to pause mid-execution and resume later, it needs to remember where it stopped. That's what a checkpointer does. It saves the graph's full state at each node so execution can pick up exactly where it left off.
- In research_assistant.py, find your imports at the top of the file.
- Add this new import line directly above your existing langgraph.graph import:
from langgraph.checkpoint.memory import InMemorySaver
- Now find the line that imports Command:
from langgraph.types import Command
- Add interrupt to that import so it looks like this:
from langgraph.types import Command, interrupt
- Next, scroll down to your build_graph() function and find this line:
graph = builder.compile()
- Replace that line with these two lines that create a checkpointer and pass it to the compiler:
# Compile with checkpointer for interrupt/resume support
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
Why is checkpointing required for interrupts?
When a graph hits an interrupt() call, execution stops completely. Without a checkpointer, all in-flight state would be lost. InMemorySaver snapshots the graph's state at every node transition, so when you resume with Command(resume=...), the graph picks up exactly where it paused.
- Save research_assistant.py.
You won't see a change yet. The checkpointer works silently in the background. The next substep uses it when we add the interrupt to the writer agent.
Modify the writer agent to pause for approval
Right now, the writer agent saves files the moment it finishes drafting. You'll add an interrupt() call that pauses execution, surfaces the draft to you, and waits for your decision before writing anything to disk.
- Find your writer_agent function in research_assistant.py.
- Replace the entire function with this updated version that includes the interrupt and approval logic:
def writer_agent(state: ResearchState) -> Command[Literal["supervisor"]]:
"""Writer agent that drafts summaries and saves them with human approval."""
llm, prompt = create_agent(
system_prompt=(
"You are a professional writer. Based on the research provided in the "
"conversation, draft a clear, well-structured summary. Keep it concise "
"but informative. Do not use /no_think or thinking tags."
),
)
messages = [SystemMessage(content=prompt)] + state["messages"]
response = llm.invoke(messages)
draft = response.content
filename = "research_summary.md"
# Human-in-the-loop: pause for approval before saving
decision = interrupt(
{
"question": "The writer has drafted a summary. Do you approve saving it?",
"draft_preview": draft[:500] + ("..." if len(draft) > 500 else ""),
"proposed_file": filename,
"instructions": "Resume with True to approve, or False to reject.",
}
)
if decision:
save_result = save_file.invoke({"content": draft, "filename": filename})
return Command(
goto="supervisor",
update={
"messages": [
AIMessage(
content=f"[Writer Agent] Draft saved. {save_result}"
)
],
"draft": draft,
"file_path": os.path.join(OUTPUT_DIR, filename),
"approved": True,
},
)
else:
return Command(
goto="supervisor",
update={
"messages": [
AIMessage(
content="[Writer Agent] Draft was rejected by the user. Not saved."
)
],
"draft": draft,
"approved": False,
},
)
What does this code do?
- The first half of the function is unchanged. The LLM generates a draft and stores it in the draft variable.
- interrupt() halts the graph and sends a payload (the draft preview, proposed filename, and instructions) back to the caller. The graph is now frozen.
- When the caller resumes with Command(resume=True) or Command(resume=False), that value becomes the return value of interrupt() and lands in the decision variable.
- If decision is True, the file is saved. If False, the draft is discarded and the supervisor is told the user rejected it.
- Save research_assistant.py.
You won't see a change yet. The interrupt only triggers when the graph runs and the supervisor routes to the writer. You'll test this in the next substep.
Seeing a syntax error after pasting?
- Make sure you replaced the entire writer_agent function, including the def line. Leftover lines from the old version will cause indentation or name errors.
- Check that the from langgraph.types import Command, interrupt import includes interrupt. A missing import causes a NameError at runtime.
- Still stuck? Help me fix a syntax error in my writer_agent function after adding the interrupt() call.
Test the full approval flow
The graph can now pause, but you need a way to detect the pause and prompt yourself for a decision. The main() function handles this by checking the graph's state after each invocation. If the graph is paused at an interrupt, it displays the draft preview and asks for your approval.
- In research_assistant.py, scroll to the bottom of the file. Replace your existing test code (the if __name__ == "__main__" block) with this main() function and entry point:
# --- Main ---
def main():
graph = build_graph()
config = {"configurable": {"thread_id": "research-session-1"}}
print("=== Multi-Agent Research Assistant ===")
print("Type your request (or 'quit' to exit):\n")
while True:
user_input = input("You: ").strip()
if user_input.lower() in ["quit", "exit", "q"]:
print("Goodbye!")
break
if not user_input:
continue
# Invoke the graph
result = graph.invoke(
{
"messages": [HumanMessage(content=user_input)],
"search_results": "",
"draft": "",
"file_path": "",
"approved": False,
},
config=config,
)
# Check for interrupts
snapshot = graph.get_state(config)
if snapshot.next:
# Graph is paused at an interrupt
print("\n--- APPROVAL REQUIRED ---")
interrupt_data = snapshot.tasks[0].interrupts[0].value
print(f"Question: {interrupt_data['question']}")
print(f"File: {interrupt_data['proposed_file']}")
print(f"Preview:\n{interrupt_data['draft_preview']}")
approval = input("\nApprove? (yes/no): ").strip().lower()
is_approved = approval in ["yes", "y"]
# Resume the graph
result = graph.invoke(
Command(resume=is_approved),
config=config,
)
# Print the final messages
for msg in result["messages"]:
if isinstance(msg, AIMessage) and msg.content:
print(f"\n{msg.content}")
print()
if __name__ == "__main__":
main()
What does this code do?
- config includes a thread_id that ties all checkpoints to the same session. The checkpointer uses this ID to save and retrieve state.
- After each graph.invoke(), the code calls graph.get_state(config) to take a snapshot. If snapshot.next is not empty, the graph is paused at an interrupt and waiting for a decision.
- The interrupt payload (draft preview, proposed filename) is extracted from snapshot.tasks[0].interrupts[0].value and displayed to you.
- graph.invoke(Command(resume=is_approved)) unfreezes the graph. The boolean you pass becomes the return value of interrupt() inside the writer agent.
- Save research_assistant.py.
Awesome, I've got everything!
Double check you've saved your file before running the test.
I'd like to double check the full code
Here is the complete research_assistant.py file. Make sure yours matches exactly.
import os
from typing import Annotated, Literal, TypedDict
from langchain_community.tools import DuckDuckGoSearchResults
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_core.tools import tool
from langchain_ollama import ChatOllama
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph, add_messages
from langgraph.types import Command, interrupt
# --- State Definition ---
class ResearchState(TypedDict):
messages: Annotated[list, add_messages]
search_results: str
draft: str
file_path: str
approved: bool
# --- Tools ---
OUTPUT_DIR = "output"
@tool
def web_search(query: str) -> str:
"""Search the web for information on a given topic."""
search = DuckDuckGoSearchResults(max_results=3)
results = search.invoke(query)
return results
@tool
def save_file(content: str, filename: str) -> str:
"""Save content to a file in the output directory."""
os.makedirs(OUTPUT_DIR, exist_ok=True)
file_path = os.path.join(OUTPUT_DIR, filename)
with open(file_path, "w") as f:
f.write(content)
return f"File saved successfully to {file_path}"
@tool
def list_files() -> str:
"""List all files in the output directory."""
if not os.path.exists(OUTPUT_DIR):
return "No output directory found."
files = os.listdir(OUTPUT_DIR)
if not files:
return "No files found in the output directory."
return "\n".join(files)
# --- Agent Helper ---
def create_agent(system_prompt: str, tools: list | None = None):
"""Create a ChatOllama agent with optional tools."""
llm = ChatOllama(model="qwen3:8b", temperature=0)
if tools:
llm = llm.bind_tools(tools)
return llm, system_prompt
# --- Agent Nodes ---
def search_agent(state: ResearchState) -> Command[Literal["supervisor"]]:
"""Web search specialist agent."""
llm, prompt = create_agent(
system_prompt=(
"You are a web research specialist. Use the web_search tool to find "
"relevant information based on the user's request. Summarize what you "
"find clearly and concisely. Do not use /no_think or thinking tags."
),
tools=[web_search],
)
messages = [SystemMessage(content=prompt)] + state["messages"]
response = llm.invoke(messages)
search_results = ""
if response.tool_calls:
for tc in response.tool_calls:
if tc["name"] == "web_search":
result = web_search.invoke(tc["args"])
search_results = result
summary_llm = ChatOllama(model="qwen3:8b", temperature=0)
summary_messages = [
SystemMessage(
content="Summarize these search results concisely. Do not use /no_think or thinking tags."
),
HumanMessage(content=f"Search results:\n{search_results}"),
]
summary = summary_llm.invoke(summary_messages)
return Command(
goto="supervisor",
update={
"messages": [AIMessage(content=f"[Search Agent] {summary.content}")],
"search_results": search_results,
},
)
def writer_agent(state: ResearchState) -> Command[Literal["supervisor"]]:
"""Writer agent that drafts summaries and saves them with human approval."""
llm, prompt = create_agent(
system_prompt=(
"You are a professional writer. Based on the research provided in the "
"conversation, draft a clear, well-structured summary. Keep it concise "
"but informative. Do not use /no_think or thinking tags."
),
)
messages = [SystemMessage(content=prompt)] + state["messages"]
response = llm.invoke(messages)
draft = response.content
filename = "research_summary.md"
# Human-in-the-loop: pause for approval before saving
decision = interrupt(
{
"question": "The writer has drafted a summary. Do you approve saving it?",
"draft_preview": draft[:500] + ("..." if len(draft) > 500 else ""),
"proposed_file": filename,
"instructions": "Resume with True to approve, or False to reject.",
}
)
if decision:
save_result = save_file.invoke({"content": draft, "filename": filename})
return Command(
goto="supervisor",
update={
"messages": [
AIMessage(
content=f"[Writer Agent] Draft saved. {save_result}"
)
],
"draft": draft,
"file_path": os.path.join(OUTPUT_DIR, filename),
"approved": True,
},
)
else:
return Command(
goto="supervisor",
update={
"messages": [
AIMessage(
content="[Writer Agent] Draft was rejected by the user. Not saved."
)
],
"draft": draft,
"approved": False,
},
)
def qa_agent(state: ResearchState) -> Command[Literal["supervisor"]]:
"""Q&A agent that answers questions using conversation context."""
llm, prompt = create_agent(
system_prompt=(
"You are a knowledgeable assistant. Answer the user's question based on "
"the information available in the conversation history. Be accurate and "
"helpful. Do not use /no_think or thinking tags."
),
)
messages = [SystemMessage(content=prompt)] + state["messages"]
response = llm.invoke(messages)
return Command(
goto="supervisor",
update={
"messages": [AIMessage(content=f"[Q&A Agent] {response.content}")],
},
)
# --- Supervisor Node ---
def supervisor(state: ResearchState) -> Command[Literal["search_agent", "writer_agent", "qa_agent", "__end__"]]:
"""Supervisor that routes tasks to the appropriate specialist agent."""
llm = ChatOllama(model="qwen3:8b", temperature=0)
route_prompt = (
"You are a supervisor managing a team of specialist agents. "
"Based on the conversation, decide which agent should handle the next step.\n\n"
"Available agents:\n"
"- search_agent: For finding information on the web\n"
"- writer_agent: For drafting and saving summaries or documents\n"
"- qa_agent: For answering questions about gathered information\n"
"- FINISH: When the task is complete and no more work is needed\n\n"
"Respond with ONLY one of: search_agent, writer_agent, qa_agent, FINISH\n"
"Do not explain your reasoning. Do not use /no_think or thinking tags. "
"Just output the agent name or FINISH."
)
messages = [SystemMessage(content=route_prompt)] + state["messages"]
response = llm.invoke(messages)
route = response.content.strip().lower()
# Clean up the response to extract the route
for agent_name in ["search_agent", "writer_agent", "qa_agent", "finish"]:
if agent_name in route:
route = agent_name
break
if route == "finish":
return Command(
goto=END,
update={
"messages": [
AIMessage(
content="[Supervisor] Task complete. All agents have finished their work."
)
],
},
)
if route not in ["search_agent", "writer_agent", "qa_agent"]:
# Default to qa_agent if routing is unclear
route = "qa_agent"
return Command(
goto=route,
update={
"messages": [
AIMessage(content=f"[Supervisor] Routing to {route}...")
],
},
)
# --- Build the Graph ---
def build_graph():
"""Build and compile the multi-agent research assistant graph."""
builder = StateGraph(ResearchState)
# Add nodes
builder.add_node("supervisor", supervisor)
builder.add_node("search_agent", search_agent)
builder.add_node("writer_agent", writer_agent)
builder.add_node("qa_agent", qa_agent)
# Entry point
builder.add_edge(START, "supervisor")
# Compile with checkpointer for interrupt/resume support
checkpointer = InMemorySaver()
graph = builder.compile(checkpointer=checkpointer)
return graph
# --- Main ---
def main():
graph = build_graph()
config = {"configurable": {"thread_id": "research-session-1"}}
print("=== Multi-Agent Research Assistant ===")
print("Type your request (or 'quit' to exit):\n")
while True:
user_input = input("You: ").strip()
if user_input.lower() in ["quit", "exit", "q"]:
print("Goodbye!")
break
if not user_input:
continue
# Invoke the graph
result = graph.invoke(
{
"messages": [HumanMessage(content=user_input)],
"search_results": "",
"draft": "",
"file_path": "",
"approved": False,
},
config=config,
)
# Check for interrupts
snapshot = graph.get_state(config)
if snapshot.next:
# Graph is paused at an interrupt
print("\n--- APPROVAL REQUIRED ---")
interrupt_data = snapshot.tasks[0].interrupts[0].value
print(f"Question: {interrupt_data['question']}")
print(f"File: {interrupt_data['proposed_file']}")
print(f"Preview:\n{interrupt_data['draft_preview']}")
approval = input("\nApprove? (yes/no): ").strip().lower()
is_approved = approval in ["yes", "y"]
# Resume the graph
result = graph.invoke(
Command(resume=is_approved),
config=config,
)
# Print the final messages
for msg in result["messages"]:
if isinstance(msg, AIMessage) and msg.content:
print(f"\n{msg.content}")
print()
if __name__ == "__main__":
main()
Time to test the full approval flow. You'll run the assistant, trigger a search-and-write request, and see the graph pause for your approval.
- Run the research assistant by executing this command in your terminal:
python research_assistant.py
- When the You: prompt appears, type a request that triggers both search and writing, like:
Research the latest trends in quantum computing and save a summary.
- Wait for the supervisor to route the request to the search agent, then to the writer agent. This can take 30-60 seconds depending on your machine.
What should I see?
The supervisor routes to the search agent first. After the search completes, the supervisor routes to the writer agent. The writer drafts a summary and the graph pauses, printing:
--- APPROVAL REQUIRED ---
Question: The writer has drafted a summary. Do you approve saving it?
File: research_summary.md
Preview:
[A preview of the draft summary appears here]
Approve? (yes/no):
- Type yes and press Enter to approve the save.
- Verify the file was saved by running this command in a new terminal window:
ls output/
You should see research_summary.md listed in the output.
Now test the rejection path to make sure it works too.
- Delete the output file so you can test cleanly by running:
rm -rf output/
- Run the assistant again with python research_assistant.py and submit the same request.
- When the approval prompt appears, type no and press Enter.
- Verify the file was NOT saved by running:
ls output/
You should see No such file or directory or an empty directory. The writer drafted the summary but respected your rejection and did not save it.
Graph not pausing at the approval step?
- Make sure your build_graph() function passes the checkpointer to builder.compile(checkpointer=checkpointer). Without the checkpointer, interrupt() raises an error instead of pausing.
- If the supervisor routes to a different agent instead of the writer, try a more explicit request like "Search for AI trends and write a summary to a file." The routing depends on the LLM's interpretation of your prompt.
- If you see a ValueError about checkpointing, confirm that from langgraph.checkpoint.memory import InMemorySaver is at the top of your file. The older MemorySaver is deprecated.
- Still stuck? Help me debug why my LangGraph interrupt isn't pausing the graph for human approval.
Secret mission
Add a Critic Agent
Your multi-agent system drafts and saves summaries, but how do you ensure quality before a human even sees the output? In this extension, you'll add a fourth agent that reviews the writer's work and sends it back for revision when it doesn't meet the bar.
Before you go
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- Project directory with research_assistant.py.
- Python virtual environment created with pyenv.
- The output/ directory containing generated research summaries.
- Ollama model qwen3:8b (approximately 5.2GB on disk).
- Installed Python packages (langgraph, langchain-ollama, langchain-community, langchain-core, ddgs).
Keep everything running
No action needed. Choose this if you're still actively building or plan to extend the project with more agents.
- Your project directory, virtual environment, and output files stay as they are.
- The qwen3:8b model remains available in Ollama for other projects.
- Everything is local, so nothing is costing you money.
Pause - I'll come back to this later
Free up disk space from generated files while keeping the project ready to resume.
- Delete the generated output files by running the following command from your project directory:
rm -rf output/
- Keep your project directory and virtual environment so you can pick up where you left off.
- Keep the qwen3:8b model if you plan to use Ollama for other projects.
Delete - I don't want to use this again
Remove all project resources and start fresh.
- Delete the entire project directory (including the virtual environment and output files).
cd ..
rm -rf research-assistant/
- If you no longer need the Ollama model, remove it to free up approximately 5.2GB of disk space:
ollama rm qwen3:8b
- If you use Ollama for other projects, you can keep the application itself installed. Only remove qwen3:8b if you want the disk space back.
Mission accomplished
Nice Work!
You just built a fully local, multi-agent research assistant using LangGraph and Ollama. This is the same supervisor orchestration pattern used in production by companies like Uber and LinkedIn.
Here's what you accomplished:
- Built a supervisor-pattern multi-agent system using LangGraph's StateGraph and Command API, where a central supervisor dynamically routes tasks to specialized agents at runtime.
- Created three specialized agents (search, writer, and Q&A) that collaborate through shared typed state, with tool calling powered by qwen3:8b running entirely on your local machine.
- Added human-in-the-loop approval using LangGraph's interrupt() and Command(resume=...) mechanism, backed by checkpointing with InMemorySaver for durable graph execution.
- Built a critic agent that reviews the writer's drafts and creates a revision loop before human approval.
Ready to quiz yourself?