Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# 🧱 Deep Research From Scratch
# 🧱 Deep Research From Scratch

Deep research has broken out as one of the most popular agent applications. [OpenAI](https://openai.com/index/introducing-deep-research/), [Anthropic](https://www.anthropic.com/engineering/built-multi-agent-research-system), [Perplexity](https://www.perplexity.ai/hub/blog/introducing-perplexity-deep-research), and [Google](https://gemini.google/overview/deep-research/?hl=en) all have deep research products that produce comprehensive reports using [various sources](https://www.anthropic.com/news/research) of context. There are also many [open](https://huggingface.co/blog/open-deep-research) [source](https://github.com/google-gemini/gemini-fullstack-langgraph-quickstart) implementations. We built an [open deep researcher](https://github.com/langchain-ai/open_deep_research) that is simple and configurable, allowing users to bring their own models, search tools, and MCP servers. In this repo, we'll build a deep researcher from scratch! Here is a map of the major pieces that we will build:

![overview](https://github.com/user-attachments/assets/b71727bd-0094-40c4-af5e-87cdb02123b4)

## 🚀 Quickstart
## 🚀 Quickstart

### Prerequisites

Expand Down Expand Up @@ -35,6 +35,16 @@ curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="/Users/$USER/.local/bin:$PATH"
```


- Install necessary packages.
```bash
pip install langgraph langchain_core langchain langchain-openai
pip install langchain-anthropic langchain_mcp_adapters mcp tavily-python
pip install --upgrade jupyterlab ipywidgets

```


### Installation

1. Clone the repository:
Expand Down Expand Up @@ -79,17 +89,17 @@ source .venv/bin/activate # On Windows: .venv\Scripts\activate
jupyter notebook
```

## Background
## Background

Research is an open‑ended task; the best strategy to answer a user request can’t be easily known in advance. Requests can require different research strategies and varying levels of search depth. Consider this request.
Research is an open‑ended task; the best strategy to answer a user request can’t be easily known in advance. Requests can require different research strategies and varying levels of search depth. Consider this request.

[Agents](https://langchain-ai.github.io/langgraph/tutorials/workflows/#agent) are well suited to research because they can flexibly apply different strategies, using intermediate results to guide their exploration. Open deep research uses an agent to conduct research as part of a three step process:

1. **Scope** – clarify research scope
2. **Research** – perform research
3. **Write** – produce the final report

## 📝 Organization
## 📝 Organization

This repo contains 5 tutorial notebooks that build a deep research system from scratch:

Expand Down Expand Up @@ -201,4 +211,4 @@ This repo contains 5 tutorial notebooks that build a deep research system from s
- **State Management**: Complex state flows across subgraphs and nodes
- **Protocol Integration**: MCP servers and tool ecosystems

Each notebook builds on the previous concepts, culminating in a production-ready deep research system that can handle complex, multi-faceted research queries with intelligent scoping and coordinated execution.
Each notebook builds on the previous concepts, culminating in a production-ready deep research system that can handle complex, multi-faceted research queries with intelligent scoping and coordinated execution.
255 changes: 183 additions & 72 deletions notebooks/1_scoping.ipynb

Large diffs are not rendered by default.

2,374 changes: 1,154 additions & 1,220 deletions notebooks/2_research_agent.ipynb

Large diffs are not rendered by default.

676 changes: 262 additions & 414 deletions notebooks/3_research_agent_mcp.ipynb

Large diffs are not rendered by default.

1,208 changes: 736 additions & 472 deletions notebooks/4_research_supervisor.ipynb

Large diffs are not rendered by default.

51 changes: 29 additions & 22 deletions src/deep_research_from_scratch/multi_agent_supervisor.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@
maintaining isolated context windows for each research topic.
"""

import asyncio
import os, asyncio

from typing_extensions import Literal

from langchain.chat_models import init_chat_model
from langchain_openai import AzureChatOpenAI
from langchain_core.messages import (
HumanMessage,
BaseMessage,
Expand All @@ -36,16 +37,16 @@

def get_notes_from_tool_calls(messages: list[BaseMessage]) -> list[str]:
"""Extract research notes from ToolMessage objects in supervisor message history.

This function retrieves the compressed research findings that sub-agents
return as ToolMessage content. When the supervisor delegates research to
sub-agents via ConductResearch tool calls, each sub-agent returns its
compressed findings as the content of a ToolMessage. This function
extracts all such ToolMessage content to compile the final research notes.

Args:
messages: List of messages from supervisor's conversation history

Returns:
List of research note strings extracted from ToolMessage objects
"""
Expand All @@ -68,7 +69,13 @@ def get_notes_from_tool_calls(messages: list[BaseMessage]) -> list[str]:
# ===== CONFIGURATION =====

supervisor_tools = [ConductResearch, ResearchComplete, think_tool]
supervisor_model = init_chat_model(model="anthropic:claude-sonnet-4-20250514")
# supervisor_model = init_chat_model(model="anthropic:claude-sonnet-4-20250514")
model_args = dict(azure_endpoint="https://llm-proxy.perflab.nvidia.com",
api_key=os.getenv("PERFLAB_ONEAPI"),
api_version="2025-02-01-preview",)
supervisor_model = AzureChatOpenAI(
deployment_name=os.getenv("RESEARCH_MODEL", "claude-sonnet-4-20250514"),
temperature=0.0, **model_args)
supervisor_model_with_tools = supervisor_model.bind_tools(supervisor_tools)

# System constants
Expand All @@ -84,31 +91,31 @@ def get_notes_from_tool_calls(messages: list[BaseMessage]) -> list[str]:

async def supervisor(state: SupervisorState) -> Command[Literal["supervisor_tools"]]:
"""Coordinate research activities.

Analyzes the research brief and current progress to decide:
- What research topics need investigation
- Whether to conduct parallel research
- When research is complete

Args:
state: Current supervisor state with messages and research progress

Returns:
Command to proceed to supervisor_tools node with updated state
"""
supervisor_messages = state.get("supervisor_messages", [])

# Prepare system message with current date and constraints
system_message = lead_researcher_prompt.format(
date=get_today_str(),
max_concurrent_research_units=max_concurrent_researchers,
max_researcher_iterations=max_researcher_iterations
)
messages = [SystemMessage(content=system_message)] + supervisor_messages

# Make decision about next research steps
response = await supervisor_model_with_tools.ainvoke(messages)

return Command(
goto="supervisor_tools",
update={
Expand All @@ -119,41 +126,41 @@ async def supervisor(state: SupervisorState) -> Command[Literal["supervisor_tool

async def supervisor_tools(state: SupervisorState) -> Command[Literal["supervisor", "__end__"]]:
"""Execute supervisor decisions - either conduct research or end the process.

Handles:
- Executing think_tool calls for strategic reflection
- Launching parallel research agents for different topics
- Aggregating research results
- Determining when research is complete

Args:
state: Current supervisor state with messages and iteration count

Returns:
Command to continue supervision, end process, or handle errors
"""
supervisor_messages = state.get("supervisor_messages", [])
research_iterations = state.get("research_iterations", 0)
most_recent_message = supervisor_messages[-1]

# Initialize variables for single return pattern
tool_messages = []
all_raw_notes = []
next_step = "supervisor" # Default next step
should_end = False

# Check exit criteria first
exceeded_iterations = research_iterations >= max_researcher_iterations
no_tool_calls = not most_recent_message.tool_calls
research_complete = any(
tool_call["name"] == "ResearchComplete"
for tool_call in most_recent_message.tool_calls
)

if exceeded_iterations or no_tool_calls or research_complete:
should_end = True
next_step = END

else:
# Execute ALL tool calls before deciding next step
try:
Expand All @@ -162,7 +169,7 @@ async def supervisor_tools(state: SupervisorState) -> Command[Literal["superviso
tool_call for tool_call in most_recent_message.tool_calls
if tool_call["name"] == "think_tool"
]

conduct_research_calls = [
tool_call for tool_call in most_recent_message.tool_calls
if tool_call["name"] == "ConductResearch"
Expand Down Expand Up @@ -206,20 +213,20 @@ async def supervisor_tools(state: SupervisorState) -> Command[Literal["superviso
tool_call_id=tool_call["id"]
) for result, tool_call in zip(tool_results, conduct_research_calls)
]

tool_messages.extend(research_tool_messages)

# Aggregate raw notes from all research
all_raw_notes = [
"\n".join(result.get("raw_notes", []))
for result in tool_results
]

except Exception as e:
print(f"Error in supervisor tools: {e}")
should_end = True
next_step = END

# Single return point with appropriate state updates
if should_end:
return Command(
Expand Down
48 changes: 31 additions & 17 deletions src/deep_research_from_scratch/research_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
This module implements a research agent that can perform iterative web searches
and synthesis to answer complex research questions.
"""

import os
from pydantic import BaseModel, Field
from typing_extensions import Literal

from langgraph.graph import StateGraph, START, END
from langchain_core.messages import SystemMessage, HumanMessage, ToolMessage, filter_messages
from langchain.chat_models import init_chat_model
from langchain_openai import AzureChatOpenAI

from deep_research_from_scratch.state_research import ResearcherState, ResearcherOutputState
from deep_research_from_scratch.utils import tavily_search, get_today_str, think_tool
Expand All @@ -23,20 +24,33 @@
tools_by_name = {tool.name: tool for tool in tools}

# Initialize models
model = init_chat_model(model="anthropic:claude-sonnet-4-20250514")
model_args = dict(azure_endpoint="https://llm-proxy.perflab.nvidia.com",
api_key=os.getenv("PERFLAB_ONEAPI"),
api_version="2025-02-01-preview",)
model = AzureChatOpenAI(
deployment_name=os.getenv("RESEARCH_MODEL", "claude-sonnet-4-20250514"),
temperature=0.0, **model_args)
summarization_model = AzureChatOpenAI(
deployment_name=os.getenv("SUMMARISATION_MODEL", "gpt-4.1-mini-20250414"),
temperature=0.0, **model_args)
compress_model = AzureChatOpenAI(
deployment_name=os.getenv("COMPRESS_MODEL", "gpt-4.1-20250414"),
temperature=0.0, max_tokens=32000, **model_args)

#model = init_chat_model(model="anthropic:claude-sonnet-4-20250514")
model_with_tools = model.bind_tools(tools)
summarization_model = init_chat_model(model="openai:gpt-4.1-mini")
compress_model = init_chat_model(model="openai:gpt-4.1", max_tokens=32000) # model="anthropic:claude-sonnet-4-20250514", max_tokens=64000
#summarization_model = init_chat_model(model="openai:gpt-4.1-mini")
#compress_model = init_chat_model(model="openai:gpt-4.1", max_tokens=32000) # model="anthropic:claude-sonnet-4-20250514", max_tokens=64000

# ===== AGENT NODES =====

def llm_call(state: ResearcherState):
"""Analyze current state and decide on next actions.

The model analyzes the current conversation state and decides whether to:
1. Call search tools to gather more information
2. Provide a final answer based on gathered information

Returns updated state with the model's response.
"""
return {
Expand All @@ -49,18 +63,18 @@ def llm_call(state: ResearcherState):

def tool_node(state: ResearcherState):
"""Execute all tool calls from the previous LLM response.

Executes all tool calls from the previous LLM responses.
Returns updated state with tool execution results.
"""
tool_calls = state["researcher_messages"][-1].tool_calls

# Execute all tool calls
observations = []
for tool_call in tool_calls:
tool = tools_by_name[tool_call["name"]]
observations.append(tool.invoke(tool_call["args"]))

# Create tool message outputs
tool_outputs = [
ToolMessage(
Expand All @@ -69,28 +83,28 @@ def tool_node(state: ResearcherState):
tool_call_id=tool_call["id"]
) for observation, tool_call in zip(observations, tool_calls)
]

return {"researcher_messages": tool_outputs}

def compress_research(state: ResearcherState) -> dict:
"""Compress research findings into a concise summary.

Takes all the research messages and tool outputs and creates
a compressed summary suitable for the supervisor's decision-making.
"""

system_message = compress_research_system_prompt.format(date=get_today_str())
messages = [SystemMessage(content=system_message)] + state.get("researcher_messages", []) + [HumanMessage(content=compress_research_human_message)]
response = compress_model.invoke(messages)

# Extract raw notes from tool and AI messages
raw_notes = [
str(m.content) for m in filter_messages(
state["researcher_messages"],
include_types=["tool", "ai"]
)
]

return {
"compressed_research": str(response.content),
"raw_notes": ["\n".join(raw_notes)]
Expand All @@ -100,17 +114,17 @@ def compress_research(state: ResearcherState) -> dict:

def should_continue(state: ResearcherState) -> Literal["tool_node", "compress_research"]:
"""Determine whether to continue research or provide final answer.

Determines whether the agent should continue the research loop or provide
a final answer based on whether the LLM made tool calls.

Returns:
"tool_node": Continue to tool execution
"compress_research": Stop and compress research
"""
messages = state["researcher_messages"]
last_message = messages[-1]

# If the LLM makes a tool call, continue to tool execution
if last_message.tool_calls:
return "tool_node"
Expand Down
16 changes: 13 additions & 3 deletions src/deep_research_from_scratch/research_agent_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from typing_extensions import Literal

from langchain.chat_models import init_chat_model
from langchain_openai import AzureChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage, ToolMessage, filter_messages
from langchain_mcp_adapters.client import MultiServerMCPClient
from langgraph.graph import StateGraph, START, END
Expand Down Expand Up @@ -53,8 +54,17 @@ def get_mcp_client():
return _client

# Initialize models
compress_model = init_chat_model(model="openai:gpt-4.1", max_tokens=32000)
model = init_chat_model(model="anthropic:claude-sonnet-4-20250514")
# compress_model = init_chat_model(model="openai:gpt-4.1", max_tokens=32000)
# model = init_chat_model(model="anthropic:claude-sonnet-4-20250514")
model_args = dict(azure_endpoint="https://llm-proxy.perflab.nvidia.com",
api_key=os.getenv("PERFLAB_ONEAPI"),
api_version="2025-02-01-preview",)
model = AzureChatOpenAI(
deployment_name=os.getenv("RESEARCH_MODEL", "claude-sonnet-4-20250514"),
temperature=0.0, **model_args)
compress_model = AzureChatOpenAI(
deployment_name=os.getenv("COMPRESS_MODEL", "gpt-4.1-20250414"),
temperature=0.0, max_tokens=32000, **model_args)

# ===== AGENT NODES =====

Expand Down Expand Up @@ -145,7 +155,7 @@ def compress_research(state: ResearcherState) -> dict:
This function filters out think_tool calls and focuses on substantive
file-based research content from MCP tools.
"""

system_message = compress_research_system_prompt.format(date=get_today_str())
messages = [SystemMessage(content=system_message)] + state.get("researcher_messages", []) + [HumanMessage(content=compress_research_human_message)]

Expand Down
Loading