← Back to all articles

Design Principles of Deep Research: Lessons from LangChain's OpenDeepResearch

1. Introduction

In February 2025, OpenAI announced its Deep Research feature, and soon after, Claude, Gemini, Perplexity, GenSpark, and others followed suit with their own versions. Deep Research has since become a standard feature, with adoption spreading widely among general users.

Deep Research has transformed tasks that previously took days of manual investigation, or the kind of research work that junior consultants at consulting firms would spend nearly a week compiling, into high-quality outputs delivered in just minutes to tens of minutes. I personally use it daily for exploring adjacent fields and researching unfamiliar industries, and I've reached a point where I simply cannot go back to life without Deep Research.

While general adoption of Deep Research has progressed significantly, issuing instructions through a GUI every time can become tedious. There is a growing need to integrate Deep Research directly into existing chat tools and internal applications so it can be used seamlessly within business processes. Responding to this demand, OpenAI released its Deep Research API in June 2025, and Gemini followed with its own Deep Research API in December 2025.

As data sources, vector stores, shared drives, email applications, and even MCP can now be specified. Going forward, Deep Research functionality is expected to expand beyond general research based on public information to cross-organizational use cases spanning both internal and external data.

While further expansion of Deep Research is anticipated, the term "Deep Research" encompasses a wide range of approaches. There is no single correct answer for research tasks. Each provider has its own design philosophy, and responses to the same instructions can differ dramatically. I personally run Deep Research across multiple products in parallel and compare the results.

Around 2023-2024, the novelty of the technology itself was enough, and simply deploying ChatGPT company-wide was considered a goal. However, with the rise of AI agents from 2025 onward, we have entered a phase where the real question is: "How do we generate actual business value?"

The same applies to Deep Research. What matters is not simply using the latest Deep Research tool, but designing Deep Research to match your specific use cases and the value you want to deliver.

Use cases vary by organization and workflow. For example, consider the following scenarios:

  1. Speed-First

    Used on the go or while commuting, so response speed is the top priority. Prefers slide-format output with key points summarized rather than detailed prose.

  2. Cost-Optimized

    Designed for high-frequency use across an entire department, so cost is the primary constraint, with quality being an acceptable trade-off. Simple text output is sufficient.

  3. Quality-First

    Used only a few times per month, but accuracy and comprehensiveness of content are paramount. Long execution times per run are acceptable, and cost is not a constraint.

  4. Fact-Strict

    Used as reference material for board meetings and executive briefings, so only facts backed by primary sources are included. Speculation, implications, and opinions are strictly excluded (emphasis on citations).

  5. Insight-Seeking

    Used for brainstorming and strategic planning, prioritizing the discovery of new perspectives and discussion points over comprehensive fact compilation. Cross-industry and international case studies are actively included.

Naturally, no single Deep Research system can satisfy all requirements. While prompts can adjust behavior to some extent, this is fundamentally a design-layer concern that includes context engineering.

Since each provider's Deep Research is a black box, the specific implementation details are unknown. However, LangChain has published an open-source project called "OpenDeepResearch."

https://github.com/langchain-ai/open_deep_research

While keeping an eye on the evolution of each provider's Deep Research capabilities, those in positions driving DX and AI adoption should strive to understand the full picture of Deep Research from a deeper perspective, rather than remaining mere users. This understanding will be critical for practical application and UX design.

Having thoroughly read through the source code myself, I found the learning experience extremely valuable. In this article, I will use the above repository as a case study to summarize the key design principles of Deep Research and the key considerations for practical use.

2. What is OpenDeepResearch

For an overview of OpenDeepResearch, the following LangChain blog post provides an excellent introduction:

The overall flow consists of three major phases: Scope Definition, Research, and Report Generation.

Since the quality of investigation fundamentally depends on proper scope definition, a User Clarification layer is explicitly included. If there are ambiguities, the system asks the user follow-up questions iteratively.

For the Research phase, the architecture separates a Supervisor from Research sub-agents. The Supervisor generates research topics, and each sub-agent investigates its assigned topic in parallel, improving response speed while preventing context bloat.

For cases where the scope of investigation is particularly broad, increasing the maximum number of parallel sub-agents can be expected to deliver significant speedups.

Finally, there is the Report Generation module. The fact that this is labeled "One-Shot Report Generation" is a critically important design point. I personally encountered significant challenges with this in a past project, which I will discuss in detail later.

While the explanation so far might give you a sense of understanding, when you actually start thinking about "How would I implement this?", you quickly realize there are an enormous number of design decisions to make.

If you gave this diagram to 10 engineers and asked them to implement it, you would end up with 10 different Deep Research systems, each reflecting its developer's design philosophy.

In fact, this open-source project's original architecture has already been deprecated, and the current architecture differs significantly. Since there is no universally correct approach to Deep Research as a task, no one can definitively say which implementation is best. Ultimately, organizations that can flexibly leverage Deep Research, adjusting breadth, depth, speed, and cost to match their use cases, will be the strongest.

While there is no universal answer, the design philosophy of LangChain's engineers, whose approach has become near de facto in the open-source space, offers tremendous learning value. In this article, we will dive deep into the actual prompts, graphs, and state designs at the implementation level.

3. Overall Architecture

The repository's README includes the following graph. When you follow the QuickStart instructions to launch a local server, this graph is displayed.

OpenDeepResearch Architecture Graph

At first glance it looks straightforward, but you quickly get lost, particularly at the research_supervisor section, wondering "Where does the actual research happen?"

The diagram above only shows the first level of hierarchy. When you examine the actual code, three graph structures are defined in LangGraph:

=========================
1. Main Deep Research Graph Definition
=========================

# Main Deep Researcher Graph Construction
# Creates the complete deep research workflow from user input to final report
deep_researcher_builder = StateGraph(
    AgentState,
    input=AgentInputState,
    config_schema=Configuration
)

# Add main workflow nodes for the complete research process
deep_researcher_builder.add_node("clarify_with_user", clarify_with_user)              # User clarification phase
deep_researcher_builder.add_node("write_research_brief", write_research_brief)        # Research planning phase
deep_researcher_builder.add_node("research_supervisor", supervisor_subgraph)          # Research execution phase
deep_researcher_builder.add_node("final_report_generation", final_report_generation)  # Report generation phase

# Define main workflow edges for sequential execution
deep_researcher_builder.add_edge(START, "clarify_with_user")                       # Entry point
deep_researcher_builder.add_edge("research_supervisor", "final_report_generation") # Research to report
deep_researcher_builder.add_edge("final_report_generation", END)                   # Final exit point

# Compile the complete deep researcher workflow
deep_researcher = deep_researcher_builder.compile()

=========================
2. Supervisor Subgraph
=========================

# Supervisor Subgraph Construction
# Creates the supervisor workflow that manages research delegation and coordination
supervisor_builder = StateGraph(SupervisorState, config_schema=Configuration)

# Add supervisor nodes for research management
supervisor_builder.add_node("supervisor", supervisor)              # Main supervisor logic
supervisor_builder.add_node("supervisor_tools", supervisor_tools)  # Tool execution handler

# Define supervisor workflow edges
supervisor_builder.add_edge(START, "supervisor")  # Entry point to supervisor

# Compile supervisor subgraph for use in main workflow
supervisor_subgraph = supervisor_builder.compile()

=========================
3. Researcher Agent Subgraph (Parallelizable)
=========================

# Researcher Subgraph Construction
# Creates individual researcher workflow for conducting focused research on specific topics
researcher_builder = StateGraph(
    ResearcherState,
    output=ResearcherOutputState,
    config_schema=Configuration
)

# Add researcher nodes for research execution and compression
researcher_builder.add_node("researcher", researcher)                 # Main researcher logic
researcher_builder.add_node("researcher_tools", researcher_tools)     # Tool execution handler
researcher_builder.add_node("compress_research", compress_research)   # Research compression

# Define researcher workflow edges
researcher_builder.add_edge(START, "researcher")           # Entry point to researcher
researcher_builder.add_edge("compress_research", END)      # Exit point after compression

# Compile researcher subgraph for parallel execution by supervisor
researcher_subgraph = researcher_builder.compile()

In practice, the researcher_subgraph is invoked from within supervisor_tools, where the actual research takes place. Once the supervisor determines that the responses from the research agents are sufficient, it moves to final report generation.

Looking at this alone, there is not necessarily a need to separate the supervisor into its own subgraph. Since the supervisor is called serially, it could have been expressed within the main graph directly.

However, the decision to separate it likely stems from considerations about clarifying phase boundaries, isolating state, and enabling future extensions such as replacing the supervisor itself or parallelizing it. This is one of the areas where the developers' design philosophy is strongly evident.

Before diving into each module, let's trace the overall processing flow along the graph.

  1. User requests a research investigation (query) to Deep Research
  2. The clarify_with_user node receives the user's query. If the research scope is clear, proceed to the next step. If it is ambiguous and requires clarification, return questions to the user and pause (this repeats until the research scope is clear)
  3. Once clarification is complete, the write_research_brief node generates a research brief (what to investigate, to what extent, and how)
  4. The research brief is passed to the supervisor, which generates a concrete research plan and the topics needed for investigation. Each topic is generated as an independently investigable unit
  5. A researcher is spawned for each topic, conducting investigation using its assigned search tools
  6. When investigation of the target topic is deemed sufficient, the researcher summarizes the findings and returns them to the supervisor (if insufficient, research continues iteratively)
  7. The supervisor reviews the findings from each topic and checks whether the content needed for report generation is covered. If sufficient, proceed; if not, conduct additional research (repeatable up to a maximum number of iterations)
  8. Once research is complete, the final_report_generation node generates the final report based on the research findings and returns the results to the user

By having the supervisor and each researcher operate independently, the design prevents context bloat (rather than simply accumulating research results into a shared context). Additionally, since user clarification and additional research are structured as loops, the prompt design for determining when to exit these loops is critically important. These definitions significantly affect Deep Research's speed, cost, and report quality.

Given the multi-stage nature of this process, the importance of traceability becomes clearly apparent. When the final report falls short, you need to understand whether the problem lies in the user clarification stage (clarify_with_user), the research methodology (supervisor-research), or the report generation itself (final_report_generation). Without properly identifying the true bottleneck, your corrective actions may not lead to improvement.

In AI agents, information is generated and processed as it flows through a pipeline. While ensuring traceability through trace tools like LangSmith, you also need to properly understand the processing itself to identify where problems occur and take appropriate action.

Let's now dive deeper into the state, prompts, and tool design at the module level.

From here on, the discussion becomes quite detailed (developer-level). If implementation details are not your focus, feel free to skip ahead to "5. Design Points Summary."

4. Module Explanation

clarify_with_user_instructions

The first module handles confirming the research scope with the user.

In research tasks, this initial alignment with the requester is arguably the most critical step.

This applies equally to AI. When the research scope, approach, and expected output are aligned at high resolution between the requester (user) and the investigator (AI), the result is a high-quality report.

Conversely, if research proceeds with vague instructions and unclear confirmation, no matter how much time and effort the investigator puts into the report, it may prove worthless.

You have likely experienced setbacks caused by insufficient alignment, whether as a subordinate executing a task or as a manager delegating one.

The system prompt for this module is as follows:

clarify_with_user_instructions="""
These are the messages that have been exchanged so far from the user asking for the report:
<Messages>
{messages}
</Messages>

Today's date is {date}.

Assess whether you need to ask a clarifying question, or if the user has already provided enough information for you to start research.
IMPORTANT: If you can see in the messages history that you have already asked a clarifying question, you almost always do not need to ask another one. Only ask another question if ABSOLUTELY NECESSARY.

If there are acronyms, abbreviations, or unknown terms, ask the user to clarify.
If you need to ask a question, follow these guidelines:
- Be concise while gathering all necessary information
- Make sure to gather all the information needed to carry out the research task in a concise, well-structured manner.
- Use bullet points or numbered lists if appropriate for clarity. Make sure that this uses markdown formatting and will be rendered correctly if the string output is passed to a markdown renderer.
- Don't ask for unnecessary information, or information that the user has already provided. If you can see that the user has already provided the information, do not ask for it again.

Respond in valid JSON format with these exact keys:
"need_clarification": boolean,
"question": "<question to ask the user to clarify the report scope>",
"verification": "<verification message that we will start research>"

If you need to ask a clarifying question, return:
"need_clarification": true,
"question": "<your clarifying question>",
"verification": ""

If you do not need to ask a clarifying question, return:
"need_clarification": false,
"question": "",
"verification": "<acknowledgement message that you will now start research based on the provided information>"

For the verification message when no clarification is needed:
- Acknowledge that you have sufficient information to proceed
- Briefly summarize the key aspects of what you understand from their request
- Confirm that you will now begin the research process
- Keep the message concise and professional
"""

One interesting point right away is that today's date is passed at the beginning of the prompt.

Naturally, the LLM itself is a snapshot from a specific point in time, so it does not know what today's date is. If you have ever built your own AI agent application, you may have experienced the early mistake of prompting "add today's date to the file name" only to find the file generated with an incorrect (past) date.

Since the LLM has no direct means to retrieve the current date, this information must be provided somehow. While you could pass a simple tool for retrieving the date, unless there is a need to get real-time timestamps for logging purposes, embedding it directly in the system prompt is more reasonable as it saves context consumption from tool calls.

Now to the substance of the prompt. The first thing you notice is how strongly the instructions emphasize "do not ask the same questions or ask unnecessary questions." The relevant section being marked as "IMPORTANT" is also notable.

Obviously, most requesters find it highly frustrating to be asked the same thing repeatedly or to be asked unnecessary questions. The design leans toward broadening the user base from an accessibility standpoint. Meanwhile, the requirements for information sufficiency are kept at a relatively loose level of "sufficient for research."

In other words, rather than being meticulously thorough in confirmation, the prompt prioritizes avoiding repeated questions at all costs and starting research once a reasonable amount of information has been gathered.

If you have used OpenAI's Deep Research, you know that it similarly includes a step to confirm the research scope at the beginning. Opinions on this vary, but my initial impression was "Is this really enough?"

This was because after issuing a rough request, I was asked only one confirmation question, and even without a particularly detailed response, the research proceeded. In a real work situation, I would have stopped and said, "Wait, I've only shared a rough outline. Let's align on the expected output before you start, to avoid rework."

This is entirely in the realm of design, and there is no right answer; it depends on the use case. If the current priority is to introduce the concept of Deep Research within the organization, designing the system to ask many questions would discourage users from the start, so keeping it loose like this is advisable.

On the other hand, for generating reports with real business value, this level of confirmation is arguably insufficient.

For example, in research tasks, even a quick brainstorm reveals numerous confirmation points:

<Confirmation Points for Research and Report Creation (Examples)>

  • Output format: Text only, chart-heavy, or a mix of both (what ratio is preferred)?
  • Output volume: A one-page summary, 5-10 pages, or a comprehensive 20-30 page document for thoroughness?
  • File format: PDF, Markdown for wiki integration, editable PPTX, or HTML for web display?
  • Executive summary: Should it appear at the beginning, at the end, or not at all? If included, what length is preferred?
  • Tone of writing: Formal report-style, reader-friendly casual tone, or should definitive statements be avoided?
  • Time period: Should historical trends be considered, or is the past 10 years sufficient? Compare pre- and post-COVID, or just the past year?
  • Geographic scope: Include international case studies (if so, which countries to focus on or exclude)? Domestic only (any specific regions to deep-dive)?

Output quality is entirely dependent on the requester's expectations. There is no absolute standard of "high quality"; quality is determined by whether the output matches expectations. If someone needs a one-page Word summary for a quick internal meeting discussion, receiving a polished 30-page PowerPoint report with refined graphics and charts would actually be considered low quality.

Ideally, users would provide all such information as detailed input, but in practice, this rarely happens. While everyone acknowledges that prompt engineering is important, few people actually want to write detailed prompts. The argument "the output is bad because the input quality is low" is half-correct, but repeating this claim will only drive users away.

The key is for designers to pre-configure as much as possible, reverse-engineering from "Given this use case, what output would be best?" to ensure user input requirements are minimized. If users are accessing the system on mobile while commuting, they cannot reasonably type long, detailed prompts.

For example, if a sales department uses this for pre-meeting research, the ideal design would allow them to simply select a company name and receive the business overview and organization-specific information they need at the right level of detail. Dropdown selections for research period, analytical lens, and other parameters might also be useful. If there is also a need for quick lookups right before meetings, offering a lightweight version that delivers results in minutes would be beneficial.

User input effort and output quality are fundamentally a trade-off. The challenge of "How to deliver what users want while minimizing their effort" is where engineering skill shows. This is precisely why domain knowledge is considered critical in engineering. No matter how skilled you are in AI agent development, you cannot create this design without domain knowledge. Conversely, simply asking business users to "describe your use case in as much detail as possible" will only leave them confused.

To avoid becoming a case of technology-push, it is important to patiently interview business users from their perspective, show outputs early, and iterate through PDCA cycles.

From a practical standpoint, using this standard module as-is is clearly insufficient. This perspective also highlights the importance of open source, allowing you to reuse the framework while freely customizing it.

The prompt also includes the instruction "If there are acronyms, abbreviations, or unknown terms, ask the user to clarify." While essential, having the AI ask about internal terminology every time is tedious for users. It is better to define an internal glossary in advance. If small enough, embed it in the system prompt; if large, separate it into a skill or reference that the AI can access on demand.

Keep in mind that APIs and open-source tools are published for general-purpose use and must be appropriately adapted to your specific use cases.

As for the response format of this module, it is defined in the following state:

class ClarifyWithUser(BaseModel):
    """Model for user clarification requests."""

    need_clarification: bool = Field(
        description="Whether the user needs to be asked a clarifying question.",
    )
    question: str = Field(
        description="A question to ask the user to clarify the report scope",
    )
    verification: str = Field(
        description="Verify message that we will start research after the user has provided the necessary information.",
    )

The prompt included instructions about output content. Whether additional questions are needed is output as a Boolean variable. If additional questions are needed, the response goes in question; if not, it goes in verification.

Incidentally, a configuration option is provided to skip this step entirely. If sufficient information is collected at the pre-processing stage before this module, for example through application UI selections or by defining required parameters as API arguments, it is better to disable this confirmation module.

# Step 1: Check if clarification is enabled in configuration
configurable = Configuration.from_runnable_config(config)
if not configurable.allow_clarification:
    # Skip clarification step and proceed directly to research
    return Command(goto="write_research_brief")

While this module can be summarized in a single phrase as "user scope confirmation," there are clearly numerous design considerations even at this stage.

write_research_brief

The next module creates the concrete instructions for the subsequent supervisor (research manager).

It transforms the accumulated user input and clarification exchanges into a concrete research brief.

Since this involves only LLM input/output with no tools, let's examine the system prompt:

transform_messages_into_research_topic_prompt = """You will be given a set of messages that have been exchanged so far between yourself and the user.
Your job is to translate these messages into a more detailed and concrete research question that will be used to guide the research.

The messages that have been exchanged so far between yourself and the user are:
<Messages>
{messages}
</Messages>

Today's date is {date}.

You will return a single research question that will be used to guide the research.

Guidelines:
1. Maximize Specificity and Detail
- Include all known user preferences and explicitly list key attributes or dimensions to consider.
- It is important that all details from the user are included in the instructions.

2. Fill in Unstated But Necessary Dimensions as Open-Ended
- If certain attributes are essential for a meaningful output but the user has not provided them, explicitly state that they are open-ended or default to no specific constraint.

3. Avoid Unwarranted Assumptions
- If the user has not provided a particular detail, do not invent one.
- Instead, state the lack of specification and guide the researcher to treat it as flexible or accept all possible options.

4. Use the First Person
- Phrase the request from the perspective of the user.

5. Sources
- If specific sources should be prioritized, specify them in the research question.
- For product and travel research, prefer linking directly to official or primary websites (e.g., official brand sites, manufacturer pages, or reputable e-commerce platforms like Amazon for user reviews) rather than aggregator sites or SEO-heavy blogs.
- For academic or scientific queries, prefer linking directly to the original paper or official journal publication rather than survey papers or secondary summaries.
- For people, try linking directly to their LinkedIn profile, or their personal website if they have one.
- If the query is in a specific language, prioritize sources published in that language.
"""

Guideline 1 instructs maximizing specificity and detail, with "all" emphasized repeatedly to avoid missing anything. The instruction to "explicitly list key attributes or dimensions to consider" also reveals the intent to broaden the scope from the user's rough instructions.

Guidelines 2 and 3 further indicate that when users have not provided information (no specifications), the approach should be open-ended, with no constraints, and researchers should treat it flexibly or accept all options. This emphasis on broadening scope for comprehensiveness is what makes it "Deep" Research.

For example, if you changed this instruction to "Do not investigate anything the user has not explicitly mentioned," the result would be a constrained, simple research agent.

Guideline 5 addresses information sources. The specificity of mentioning official sources and papers, and even referencing specific services like LinkedIn for people profiles, is notable.

For practical application, two key considerations become important:

  1. Aggressively filter out unnecessary information at the instruction stage

Both a strength and weakness of Deep Research, as the prompt shows, is that it prioritizes comprehensiveness. For areas without specific instructions, it broadens the scope to avoid missing any attributes or dimensions.

While this comprehensiveness is beneficial, unnecessary scope expansion leads to increased token consumption (cost), longer response times, context pollution degrading quality, and bloated reports.

Investigating areas that need not be explored deeply creates downsides for both the requester and the investigator.

For example, if someone asks about "global population trends," and what they actually need is post-2000 data for G7 and BRICS countries, the system might, in pursuit of comprehensiveness, begin researching nearly every country in the world from the earliest available statistics.

Since Deep Research will, for better or worse, also investigate unnecessary information in depth, defining the necessary scope before research begins is crucial.

During application validation, you need to iterate by reviewing generated reports and processing flows, adding prompt instructions to eliminate unnecessary research.

For example, including constraints like "domestic cases only," "within the last 3 years," "large enterprises with 10,000+ employees only," or "actual examples only, excluding hypothetical use cases" can improve report quality by clarifying scope. Designing what NOT to research is just as important as designing what to research.

Since this requires a somewhat inverted way of thinking, explicitly including an "Exclusion List" section in the prompt might be effective. Remember that uninstructed deep, comprehensive research is not always beneficial.

  1. Explicit specification of information sources

The second point is the explicit specification of information sources, which has its own dedicated section. If there are specific sources you want prioritized, they should be specified upfront. As AI-native news sites and data sources expand, including through MCP, selecting the highest-quality data sources optimized for your use case and running Deep Research exclusively against those is an excellent approach from both quality and reliability perspectives.

If facts are paramount, SNS references should generally be prohibited. Conversely, if trend analysis is the primary objective, the prompt should instruct prioritizing SNS investigation.

More than refining the downstream research agent logic, clarifying scope and organizing information sources likely has the most direct impact on quality.

The research brief generated here is then passed to the supervisor. The key point to note is that the prior exchange history is NOT included; only the instruction prompt and the generated research brief are passed.

# Transition to supervisor (excluding prior user exchange history)
return Command(
    goto="research_supervisor",
    update={
        "research_brief": response.research_brief,
        "supervisor_messages": {
            "type": "override",
            "value": [
                SystemMessage(content=supervisor_system_prompt),
                HumanMessage(content=response.research_brief)
            ]
        }
    }
)

# Supervisor State definition
class SupervisorState(TypedDict):
    """State for the supervisor that manages research tasks."""

    supervisor_messages: Annotated[list[MessageLikeRepresentation], override_reducer]
    research_brief: str
    notes: Annotated[list[str], override_reducer] = []
    research_iterations: int = 0
    raw_notes: Annotated[list[str], override_reducer] = []

This is a critically important point for context engineering. Simply accumulating all generated information leads to context bloat.

Therefore, once enough exchanges have accumulated, the content is summarized (converting user exchanges into a research brief), and only the compact summary is passed forward, preventing context bloat.

This pattern appears throughout the repository and is extremely important in AI agent design.

While overdoing it risks losing necessary information, just as keeping your desk clean periodically helps you study better, periodically tidying up the LLM's context is important for maintaining the quality of its input and output.

The research_brief is also stored in a separate variable (in addition to LLM messages) because it is referenced again during final report generation.

supervisor

Next is the supervisor. The behavior of this module is arguably the core of this Deep Research system.

An important thing to keep in mind is that the supervisor only makes decisions; all actual actions are handled by the subsequent supervisor_tools module.

Rather than the supervisor doing various things itself, it is specialized in decision-making only (selecting which tools to call), while supervisor_tools executes the instructed operations. The layers are clearly separated.

Let's look at the actual supervisor processing:

# Available tools: research delegation, completion signaling, and strategic thinking
lead_researcher_tools = [ConductResearch, ResearchComplete, think_tool]

# Configure model with tools, retry logic, and model settings
research_model = (
    configurable_model
    .bind_tools(lead_researcher_tools)
    .with_retry(stop_after_attempt=configurable.max_structured_output_retries)
    .with_config(research_model_config)
)

# Step 2: Generate supervisor response based on current context
supervisor_messages = state.get("supervisor_messages", [])
response = await research_model.ainvoke(supervisor_messages)

# Step 3: Update state and proceed to tool execution
return Command(
    goto="supervisor_tools",
    update={
        "supervisor_messages": [response],
        "research_iterations": state.get("research_iterations", 0) + 1
    }
)

Three tools are provided to the LLM: ConductResearch, ResearchComplete, and think_tool.

Let's examine each tool definition:

class ConductResearch(BaseModel):
    """Call this tool to conduct research on a specific topic."""
    research_topic: str = Field(
        description="The topic to research. Should be a single topic, and should be described in high detail (at least a paragraph).",
    )

class ResearchComplete(BaseModel):
    """Call this tool to indicate that the research is complete."""

@tool(description="Strategic reflection tool for research planning")
def think_tool(reflection: str) -> str:
    """Tool for strategic reflection on research progress and decision-making.

    Use this tool after each search to analyze results and plan next steps systematically.
    This creates a deliberate pause in the research workflow for quality decision-making.

    When to use:
    - After receiving search results: What key information did I find?
    - Before deciding next steps: Do I have enough to answer comprehensively?
    - When assessing research gaps: What specific information am I still missing?
    - Before concluding research: Can I provide a complete answer now?

    Reflection should address:
    1. Analysis of current findings - What concrete information have I gathered?
    2. Gap assessment - What crucial information is still missing?
    3. Quality evaluation - Do I have sufficient evidence/examples for a good answer?
    4. Strategic decision - Should I continue searching or provide my answer?

    Args:
        reflection: Your detailed reflection on research progress, findings, gaps, and next steps

    Returns:
        Confirmation that reflection was recorded for decision-making
    """
    return f"Reflection recorded: {reflection}"

You may have noticed something surprising: none of the tools passed here contain any actual execution logic. All actual processing is defined in supervisor_tools, and the LLM's role here is solely to decide which tools to call.

This reflects a strong design philosophy. If tool execution logic were also written here, it would become unclear where, who, and what is being processed. Since tools are expected to be extended over time, this module is kept strictly to decision-making, with actual processing including parallelization and sub-agent implementation defined in the next module.

This design pattern of clearly separating decision-making from execution is excellent for maintainability.

Since the LLM only returns tool IDs, there is no strict requirement to write tool processing here. This was an educational insight. While you could achieve similar results with StructuredOutput, since these represent event-like actions rather than state to be maintained, defining them as tools feels more intuitive.

Let's also examine the system prompt:

lead_researcher_prompt = """You are a research supervisor. Your job is to conduct research by calling the "ConductResearch" tool. For context, today's date is {date}.

<Task>
Your focus is to call the "ConductResearch" tool to conduct research against the overall research question passed in by the user.
When you are completely satisfied with the research findings returned from the tool calls, then you should call the "ResearchComplete" tool to indicate that you are done with your research.
</Task>

<Available Tools>
You have access to three main tools:
1. **ConductResearch**: Delegate research tasks to specialized sub-agents
2. **ResearchComplete**: Indicate that research is complete
3. **think_tool**: For reflection and strategic planning during research

**CRITICAL: Use think_tool before calling ConductResearch to plan your approach, and after each ConductResearch to assess progress. Do not call think_tool with any other tools in parallel.**
</Available Tools>

<Instructions>
Think like a research manager with limited time and resources. Follow these steps:

1. **Read the question carefully** - What specific information does the user need?
2. **Decide how to delegate the research** - Carefully consider the question and decide how to delegate the research. Are there multiple independent directions that can be explored simultaneously?
3. **After each call to ConductResearch, pause and assess** - Do I have enough to answer? What's still missing?
</Instructions>

<Hard Limits>
**Task Delegation Budgets** (Prevent excessive delegation):
- **Bias towards single agent** - Use single agent for simplicity unless the user request has clear opportunity for parallelization
- **Stop when you can answer confidently** - Don't keep delegating research for perfection
- **Limit tool calls** - Always stop after {max_researcher_iterations} tool calls to ConductResearch and think_tool if you cannot find the right sources

**Maximum {max_concurrent_research_units} parallel agents per iteration**
</Hard Limits>

<Show Your Thinking>
Before you call ConductResearch tool call, use think_tool to plan your approach:
- Can the task be broken down into smaller sub-tasks?

After each ConductResearch tool call, use think_tool to analyze the results:
- What key information did I find?
- What's missing?
- Do I have enough to answer the question comprehensively?
- Should I delegate more research or call ResearchComplete?
</Show Your Thinking>

<Scaling Rules>
**Simple fact-finding, lists, and rankings** can use a single sub-agent:
- *Example*: List the top 10 coffee shops in San Francisco → Use 1 sub-agent

**Comparisons presented in the user request** can use a sub-agent for each element of the comparison:
- *Example*: Compare OpenAI vs. Anthropic vs. DeepMind approaches to AI safety → Use 3 sub-agents
- Delegate clear, distinct, non-overlapping subtopics

**Important Reminders:**
- Each ConductResearch call spawns a dedicated research agent for that specific topic
- A separate agent will write the final report - you just need to gather information
- When calling ConductResearch, provide complete standalone instructions - sub-agents can't see other agents' work
- Do NOT use acronyms or abbreviations in your research questions, be very clear and specific
</Scaling Rules>"""

This is quite intricate, so let's break down the three tools in order.

First, ResearchComplete is the simplest. It is called when sufficient research results have been gathered for report generation.

# Tool definition
class ResearchComplete(BaseModel):
    """Call this tool to indicate that the research is complete."""

# System prompt instruction (excerpt)
# When you are completely satisfied with the research findings returned from the tool calls,
# call the "ResearchComplete" tool to indicate that you are done with your research.

In the subsequent supervisor_tools, the implementation defines that when this tool is called, the system transitions to END.

Next is think_tool. This is essentially a "pause and organize" reflection tool. Since repeatedly conducting research without reflection can lead to "over-researching" that diverges from the original purpose, the design encourages constant reflection.

You might wonder, "Couldn't this just be included in the system prompt?" However, in that case, it would be difficult to trace what was decided where and when, and as the context grows longer, the original instructions gradually weaken.

By making it a tool that is called at appropriate moments and stacking the reflection content in ToolMessages, the reflection content enters the most recent context window. This means subsequent research is informed by these reflections, enabling the investigation to proceed while constantly checking the gap between the original purpose and current progress.

This shares the same philosophy as the write_todos tool in DeepAgent. While the tool itself has no processing logic, it encourages specific thinking in the LLM, maintains traceability through history preservation, and refreshes the context.

Rather than thinking of "tool" = "concrete processing," it may be better to think of "tool" = "action patterns you want the LLM to take, including thinking." This tool prevents the LLM's thinking and overall processing from diverging during the Deep Research process.

Finally, conduct_research. When calling conduct_research, a single research topic is passed as an argument.

class ConductResearch(BaseModel):
    """Call this tool to conduct research on a specific topic."""
    research_topic: str = Field(
        description="The topic to research. Should be a single topic, and should be described in high detail (at least a paragraph).",
    )

In short, each research topic passed to conduct_research spawns a research agent, with each agent operating independently and in parallel. Since conducting independent investigations serially would simply waste time, parallelization (speedup) is instructed when topics can be decomposed into independent units.

However, since each agent operates independently and cannot see each other's work, the emphasis on "only when investigations can truly proceed independently" is strongly reinforced.

To summarize the overall flow:

  1. think_tool to plan the investigation
  2. conduct_research to delegate research (sub-agents run in parallel per topic)
  3. (Receive research results)
  4. think_tool to reflect on research results
  5. If additional research is needed, further conduct_research calls (return to step 2)
  6. If research is sufficient, research_complete to finish

Whether to define things in state or as tools, along with the traceability consideration of whether processing can be easily traced later in LangSmith, is an important design concern.

Rather than simply having the LLM execute its decisions, having a mechanism like think_tool that periodically outputs the LLM's thinking can also improve debugging efficiency.

supervisor_tools

Since the supervisor only handles decisions, the actual execution is handled by supervisor_tools.

You can see that it processes operations sequentially by checking which tool was called:

async def supervisor_tools(state: SupervisorState, config: RunnableConfig) -> Command[Literal["supervisor", "__end__"]]:
    """Execute tools called by the supervisor, including research delegation and strategic thinking.

    This function handles three types of supervisor tool calls:
    1. think_tool - Strategic reflection that continues the conversation
    2. ConductResearch - Delegates research tasks to sub-researchers
    3. ResearchComplete - Signals completion of research phase

    Args:
        state: Current supervisor state with messages and iteration count
        config: Runtime configuration with research limits and model settings

    Returns:
        Command to either continue supervision loop or end research phase
    """
    # Step 1: Extract current state and check exit conditions
    configurable = Configuration.from_runnable_config(config)
    supervisor_messages = state.get("supervisor_messages", [])
    research_iterations = state.get("research_iterations", 0)
    most_recent_message = supervisor_messages[-1]

    # Define exit criteria for research phase
    exceeded_allowed_iterations = research_iterations > configurable.max_researcher_iterations
    no_tool_calls = not most_recent_message.tool_calls
    research_complete_tool_call = any(
        tool_call["name"] == "ResearchComplete"
        for tool_call in most_recent_message.tool_calls
    )

    # Exit if any termination condition is met
    if exceeded_allowed_iterations or no_tool_calls or research_complete_tool_call:
        return Command(
            goto=END,
            update={
                "notes": get_notes_from_tool_calls(supervisor_messages),
                "research_brief": state.get("research_brief", "")
            }
        )

    # Step 2: Process all tool calls together (both think_tool and ConductResearch)
    all_tool_messages = []
    update_payload = {"supervisor_messages": []}

    # Handle think_tool calls (strategic reflection)
    think_tool_calls = [
        tool_call for tool_call in most_recent_message.tool_calls
        if tool_call["name"] == "think_tool"
    ]

    for tool_call in think_tool_calls:
        reflection_content = tool_call["args"]["reflection"]
        all_tool_messages.append(ToolMessage(
            content=f"Reflection recorded: {reflection_content}",
            name="think_tool",
            tool_call_id=tool_call["id"]
        ))

    # Handle ConductResearch calls (research delegation)
    conduct_research_calls = [
        tool_call for tool_call in most_recent_message.tool_calls
        if tool_call["name"] == "ConductResearch"
    ]

    if conduct_research_calls:
        try:
            # Limit concurrent research units to prevent resource exhaustion
            allowed_conduct_research_calls = conduct_research_calls[:configurable.max_concurrent_research_units]
            overflow_conduct_research_calls = conduct_research_calls[configurable.max_concurrent_research_units:]

            # Execute research tasks in parallel
            research_tasks = [
                researcher_subgraph.ainvoke({
                    "researcher_messages": [
                        HumanMessage(content=tool_call["args"]["research_topic"])
                    ],
                    "research_topic": tool_call["args"]["research_topic"]
                }, config)
                for tool_call in allowed_conduct_research_calls
            ]

            tool_results = await asyncio.gather(*research_tasks)

            # Create tool messages with research results
            for observation, tool_call in zip(tool_results, allowed_conduct_research_calls):
                all_tool_messages.append(ToolMessage(
                    content=observation.get("compressed_research", "Error synthesizing research report: Maximum retries exceeded"),
                    name=tool_call["name"],
                    tool_call_id=tool_call["id"]
                ))

            # Handle overflow research calls with error messages
            for overflow_call in overflow_conduct_research_calls:
                all_tool_messages.append(ToolMessage(
                    content=f"Error: Did not run this research as you have already exceeded the maximum number of concurrent research units. Please try again with {configurable.max_concurrent_research_units} or fewer research units.",
                    name="ConductResearch",
                    tool_call_id=overflow_call["id"]
                ))

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

            if raw_notes_concat:
                update_payload["raw_notes"] = [raw_notes_concat]

        except Exception as e:
            # Handle research execution errors
            if is_token_limit_exceeded(e, configurable.research_model) or True:
                # Token limit exceeded or other error - end research phase
                return Command(
                    goto=END,
                    update={
                        "notes": get_notes_from_tool_calls(supervisor_messages),
                        "research_brief": state.get("research_brief", "")
                    }
                )

    # Step 3: Return command with all tool results
    update_payload["supervisor_messages"] = all_tool_messages
    return Command(
        goto="supervisor",
        update=update_payload
    )

Let's review the key points.

First, termination conditions are defined at the top. The system transitions to END if: (1) research iterations exceed the maximum, (2) no tools were called, or (3) ResearchComplete was called.

Case (3), ResearchComplete, is the normal exit path, while (1) and (2) are exceptional cases. If exits via (1) or (2) are frequent, the overall design should be reconsidered.

Next is the think_tool processing. While it may appear complex at first glance, the function itself is not actually executed. Instead, the reflection content from the tool_call arguments is extracted and stacked as a ToolMessage in the context.

The all_tool_messages added here are ultimately returned to the supervisor.

"Passing back the results of your own thinking to yourself??" might be initially confusing, but supervisor_tools should not be thought of as a subordinate receiving delegated tasks. Rather, think of it as the supervisor's own hands and feet.

For think_tool specifically, imagine it as: thinking something through (supervisor) and then writing it down as a memo for yourself (supervisor_tools).

A strong design philosophy of treating everything as tool events is evident here. While stacked as ToolMessages, this is not the result of calling external processing; rather, the LLM's output is returned as if it were a tool execution result.

The research delegation section creates research_subgraph instances for each supervisor-specified topic, stored in a research_tasks array and then executed in parallel using gather.

Each research agent processes independently without sharing context. Results are stacked as ToolMessages and returned to the supervisor.

The max_concurrent_research_units setting limits maximum concurrent execution. While the supervisor's system prompt includes this number, it cannot force the LLM to strictly limit the number of topics, so overflow is handled by returning an error message to the supervisor.

researcher_subgraph

From here we enter the researcher processing. Since we are entering another subgraph, let's review its definition:

# Researcher Subgraph Construction
researcher_builder = StateGraph(
    ResearcherState,
    output=ResearcherOutputState,
    config_schema=Configuration
)

researcher_builder.add_node("researcher", researcher)
researcher_builder.add_node("researcher_tools", researcher_tools)
researcher_builder.add_node("compress_research", compress_research)

researcher_builder.add_edge(START, "researcher")
researcher_builder.add_edge("compress_research", END)

class ResearcherState(TypedDict):
    """State for individual researchers conducting research."""

    researcher_messages: Annotated[list[MessageLikeRepresentation], operator.add]
    tool_call_iterations: int = 0
    research_topic: str
    compressed_research: str
    raw_notes: Annotated[list[str], override_reducer] = []

class ResearcherOutputState(BaseModel):
    """Output state from individual researchers."""

    compressed_research: str
    raw_notes: Annotated[list[str], override_reducer] = []

This follows the same structure as the supervisor: the researcher handles decisions, researcher_tools handles execution, and compress_research consolidates results before returning them to the supervisor.

The ResearcherState class manages each research agent's input/output, with only the research_topic being passed from the supervisor.

researcher

This is the researcher's decision node, positioned identically to the supervisor but operating at the individual topic level.

Since examining the prompt first makes the design clearer, let's look at the system prompt.

Given the inherently uncertain nature of search tasks, you can see specifications for hard limits on tool calls, stop conditions, and reminders not to pursue perfection.

That said, the instructions are quite abstract. For an agent to operate "reasonably" with these instructions would require capabilities that would be considered quite advanced even for a human.

This reveals that the LLM's performance at this node is critically important.

research_system_prompt = """You are a research assistant conducting research on the user's input topic. For context, today's date is {date}.

<Task>
Your job is to use tools to gather information about the user's input topic.
You can use any of the tools provided to you to find resources that can help answer the research question. You can call these tools in series or in parallel, your research is conducted in a tool-calling loop.
</Task>

<Available Tools>
You have access to two main tools:
1. **tavily_search**: For conducting web searches to gather information
2. **think_tool**: For reflection and strategic planning during research
{mcp_prompt}

**CRITICAL: Use think_tool after each search to reflect on results and plan next steps. Do not call think_tool with the tavily_search or any other tools. It should be to reflect on the results of the search.**
</Available Tools>

<Instructions>
Think like a human researcher with limited time. Follow these steps:

1. **Read the question carefully** - What specific information does the user need?
2. **Start with broader searches** - Use broad, comprehensive queries first
3. **After each search, pause and assess** - Do I have enough to answer? What's still missing?
4. **Execute narrower searches as you gather information** - Fill in the gaps
5. **Stop when you can answer confidently** - Don't keep searching for perfection
</Instructions>

<Hard Limits>
**Tool Call Budgets** (Prevent excessive searching):
- **Simple queries**: Use 2-3 search tool calls maximum
- **Complex queries**: Use up to 5 search tool calls maximum
- **Always stop**: After 5 search tool calls if you cannot find the right sources

**Stop Immediately When**:
- You can answer the user's question comprehensively
- You have 3+ relevant examples/sources for the question
- Your last 2 searches returned similar information
</Hard Limits>

<Show Your Thinking>
After each search tool call, use think_tool to analyze the results:
- What key information did I find?
- What's missing?
- Do I have enough to answer the question comprehensively?
- Should I search more or provide my answer?
</Show Your Thinking>
"""

The available tools include: (1) think_tool (same role as in the supervisor), (2) a web search tool, and (3) MCP-related capabilities.

Since the web search tool and MCP have variations, examining the actual node reveals that tool definitions are abstracted through get_all_tools, and the mcp_prompt is injected separately:

async def get_all_tools(config: RunnableConfig):
    """Assemble complete toolkit including research, search, and MCP tools."""
    # Start with core research tools
    tools = [tool(ResearchComplete), think_tool]

    # Add configured search tools
    configurable = Configuration.from_runnable_config(config)
    search_api = SearchAPI(get_config_value(configurable.search_api))
    search_tools = await get_search_tool(search_api)
    tools.extend(search_tools)

    # Track existing tool names to prevent conflicts
    existing_tool_names = {
        tool.name if hasattr(tool, "name") else tool.get("name", "web_search")
        for tool in tools
    }

    # Add MCP tools if configured
    mcp_tools = await load_mcp_tools(config, existing_tool_names)
    tools.extend(mcp_tools)

    return tools

First, ResearchComplete and think_tool are included. This think_tool is the same one used for the supervisor.

You might think the tool should be customized for the researcher's purpose and granularity, but adding individual prompts means adding more variables that are difficult to control and evaluate.

Therefore, it is better to start with a shared form, then consider modifications based on actual input/output observations. Trying to increase variables first and then converge can lead to a tuning nightmare, so the approach of starting small, identifying bottlenecks, and expanding is preferable.

The web search tool is abstracted as search_api. The configuration allows selecting from three API types: Tavily, OpenAI, and Anthropic.

class SearchAPI(Enum):
    """Enumeration of available search API providers."""
    ANTHROPIC = "anthropic"
    OPENAI = "openai"
    TAVILY = "tavily"
    NONE = "none"

Tavily is set as the default. Going forward, this tool can be swapped as needed.

Currently the configuration selects one via enum, but in the future, parallel research using search APIs with different characteristics followed by consolidation may become more common. However, this would significantly increase token consumption in the search phase, creating a cost trade-off.

MCP is loaded via load_mcp_tools. Currently the configuration supports only one MCP connection, so modifications would be needed for multiple connections.

That said, for specialized use cases, it may be better to define tools directly rather than using MCP. MCP configuration becomes necessary when useful tools are only available through MCP.

The configured mcp_prompt (MCP description) is passed in the researcher's system prompt, enabling the researcher to call MCP-defined tools when available.

This researcher's tool definition is the most extensible point in the system. While the default is web search only, you could add tools for searching specific high-reliability site groups, vector search of internal documents, SQL queries against internal databases, and more, creating a Deep Research system that spans both internal and external information.

However, the question of whether all different sources should be parallelized is nuanced. There are cases where information found in one source informs searches in another. When clear dependencies exist, combining sequential processing is advisable. Switching tools by research phase, rather than running everything in parallel, is one viable approach.

Whether to broadly search external information and then augment with internal data, or start with internal information hits and flesh them out with external sources, depends on the use case. From a context engineering perspective, always minimizing the AI agent's choices to what is strictly necessary requires ongoing effort.

With the system prompt and tool definitions in place, the researcher makes LLM-based decisions (which tool to call) and proceeds to researcher_tools.

researcher_tools

This is where the researcher's actual processing occurs. Processing proceeds based on the researcher's execution instructions.

async def researcher_tools(state: ResearcherState, config: RunnableConfig) -> Command[Literal["researcher", "compress_research"]]:
    """Execute tools called by the researcher."""
    # Step 1: Extract current state and check early exit conditions
    configurable = Configuration.from_runnable_config(config)
    researcher_messages = state.get("researcher_messages", [])
    most_recent_message = researcher_messages[-1]

    # Early exit if no tool calls were made
    has_tool_calls = bool(most_recent_message.tool_calls)
    has_native_search = (
        openai_websearch_called(most_recent_message) or
        anthropic_websearch_called(most_recent_message)
    )

    if not has_tool_calls and not has_native_search:
        return Command(goto="compress_research")

    # Step 2: Handle tool calls
    tools = await get_all_tools(config)
    tools_by_name = {
        tool.name if hasattr(tool, "name") else tool.get("name", "web_search"): tool
        for tool in tools
    }

    # Execute all tool calls in parallel
    tool_calls = most_recent_message.tool_calls
    tool_execution_tasks = [
        execute_tool_safely(tools_by_name[tool_call["name"]], tool_call["args"], config)
        for tool_call in tool_calls
    ]
    observations = await asyncio.gather(*tool_execution_tasks)

    # Create tool messages from execution results
    tool_outputs = [
        ToolMessage(
            content=observation,
            name=tool_call["name"],
            tool_call_id=tool_call["id"]
        )
        for observation, tool_call in zip(observations, tool_calls)
    ]

    # Step 3: Check late exit conditions
    exceeded_iterations = state.get("tool_call_iterations", 0) >= configurable.max_react_tool_calls
    research_complete_called = any(
        tool_call["name"] == "ResearchComplete"
        for tool_call in most_recent_message.tool_calls
    )

    if exceeded_iterations or research_complete_called:
        return Command(
            goto="compress_research",
            update={"researcher_messages": tool_outputs}
        )

    # Continue research loop with tool results
    return Command(
        goto="researcher",
        update={"researcher_messages": tool_outputs}
    )

Nothing special is happening here; it simply executes all instructed tools using gather.

One subtle point: this time think_tool is actually executed as a function. Looking at think_tool's function definition, the reflection argument is simply returned as-is. In supervisor_tools, the argument was extracted directly and stacked as a ToolMessage without calling the function. Here, since everything is executed together via gather, the result is returned as the function's return value.

When ResearchComplete is called or the iteration limit is reached, processing moves to compress_research. If more research is needed, it returns to the researcher.

Now let's look at the actual search processing. Here is the Tavily web search implementation:

@tool(description=TAVILY_SEARCH_DESCRIPTION)
async def tavily_search(
    queries: List[str],
    max_results: Annotated[int, InjectedToolArg] = 5,
    topic: Annotated[Literal["general", "news", "finance"], InjectedToolArg] = "general",
    config: RunnableConfig = None
) -> str:
    """Fetch and summarize search results from Tavily search API."""
    # Step 1: Execute search queries asynchronously
    search_results = await tavily_search_async(
        queries, max_results=max_results, topic=topic,
        include_raw_content=True, config=config
    )

    # Step 2: Deduplicate results by URL
    unique_results = {}
    for response in search_results:
        for result in response['results']:
            url = result['url']
            if url not in unique_results:
                unique_results[url] = {**result, "query": response['query']}

    # Step 3: Set up the summarization model
    configurable = Configuration.from_runnable_config(config)
    max_char_to_include = configurable.max_content_length

    summarization_model = init_chat_model(
        model=configurable.summarization_model,
        max_tokens=configurable.summarization_model_max_tokens,
        api_key=model_api_key,
        tags=["langsmith:nostream"]
    ).with_structured_output(Summary).with_retry(
        stop_after_attempt=configurable.max_structured_output_retries
    )

    # Step 4-5: Create and execute summarization tasks in parallel
    summarization_tasks = [
        noop() if not result.get("raw_content")
        else summarize_webpage(
            summarization_model,
            result['raw_content'][:max_char_to_include]
        )
        for result in unique_results.values()
    ]
    summaries = await asyncio.gather(*summarization_tasks)

    # Step 6-7: Combine results and format output
    summarized_results = {
        url: {
            'title': result['title'],
            'content': result['content'] if summary is None else summary
        }
        for url, result, summary in zip(
            unique_results.keys(), unique_results.values(), summaries
        )
    }

    formatted_output = "Search results: \n\n"
    for i, (url, result) in enumerate(summarized_results.items()):
        formatted_output += f"\n\n--- SOURCE {i+1}: {result['title']} ---\n"
        formatted_output += f"URL: {url}\n\n"
        formatted_output += f"SUMMARY:\n{result['content']}\n\n"
        formatted_output += "\n\n" + "-" * 80 + "\n"

    return formatted_output

Tracing through the search steps, you can see that duplicate URLs are removed, and rather than returning raw search results directly, they are first summarized by an LLM.

The summarization function uses a StructuredOutput model that separates summary and key_excerpts:

class Summary(BaseModel):
    """Research summary with key findings."""
    summary: str
    key_excerpts: str

The separation of summary and key_excerpts (evidence) in the response format is particularly notable. When you want to force specific information such as citations, evidence, metadata, tags, or quantitative data in the output, rather than including them in a free-text summary, it is better to explicitly separate them via StructuredOutput for stability and enforceability in downstream processing. Here, the StructuredOutput forces both fields to be output, and the results are combined before being returned.

Through this cycle of researcher search instructions followed by researcher_tools' web search and result summarization, the necessary information is gathered. When the researcher determines the search results are sufficient, processing transitions to compress_research.

compress_research

From the researcher's perspective, this is the phase of preparing a report for the supervisor.

async def compress_research(state: ResearcherState, config: RunnableConfig):
    """Compress and synthesize research findings into a concise, structured summary."""
    # Step 1: Configure the compression model
    configurable = Configuration.from_runnable_config(config)
    synthesizer_model = configurable_model.with_config({
        "model": configurable.compression_model,
        "max_tokens": configurable.compression_model_max_tokens,
        "api_key": get_api_key_for_model(configurable.compression_model, config),
        "tags": ["langsmith:nostream"]
    })

    # Step 2: Prepare messages for compression
    researcher_messages = state.get("researcher_messages", [])
    researcher_messages.append(HumanMessage(content=compress_research_simple_human_message))

    # Step 3: Attempt compression with retry logic
    synthesis_attempts = 0
    max_attempts = 3

    while synthesis_attempts < max_attempts:
        try:
            compression_prompt = compress_research_system_prompt.format(date=get_today_str())
            messages = [SystemMessage(content=compression_prompt)] + researcher_messages
            response = await synthesizer_model.ainvoke(messages)

            raw_notes_content = "\n".join([
                str(message.content)
                for message in filter_messages(researcher_messages, include_types=["tool", "ai"])
            ])

            return {
                "compressed_research": str(response.content),
                "raw_notes": [raw_notes_content]
            }

        except Exception as e:
            synthesis_attempts += 1
            if is_token_limit_exceeded(e, configurable.research_model):
                researcher_messages = remove_up_to_last_ai_message(researcher_messages)
                continue
            continue

    # Return error if all attempts failed
    return {
        "compressed_research": "Error synthesizing research report: Maximum retries exceeded",
        "raw_notes": [raw_notes_content]
    }

The system prompt for compression:

compress_research_system_prompt = """You are a research assistant that has conducted research on a topic by calling several tools and web searches. Your job is now to clean up the findings, but preserve all of the relevant statements and information that the researcher has gathered. For context, today's date is {date}.

<Task>
You need to clean up information gathered from tool calls and web searches in the existing messages.
All relevant information should be repeated and rewritten verbatim, but in a cleaner format.
The purpose of this step is just to remove any obviously irrelevant or duplicative information.
For example, if three sources all say "X", you could say "These three sources all stated X".
Only these fully comprehensive cleaned findings are going to be returned to the user, so it's crucial that you don't lose any information from the raw messages.
</Task>

<Guidelines>
1. Your output findings should be fully comprehensive and include ALL of the information and sources that the researcher has gathered from tool calls and web searches. It is expected that you repeat key information verbatim.
2. This report can be as long as necessary to return ALL of the information that the researcher has gathered.
3. In your report, you should return inline citations for each source that the researcher found.
4. You should include a "Sources" section at the end of the report that lists all of the sources the researcher found with corresponding citations, cited against statements in the report.
5. Make sure to include ALL of the sources that the researcher gathered in the report, and how they were used to answer the question!
6. It's really important not to lose any sources. A later LLM will be used to merge this report with others, so having all of the sources is critical.
</Guidelines>

<Output Format>
The report should be structured like this:
**List of Queries and Tool Calls Made**
**Fully Comprehensive Findings**
**List of All Relevant Sources (with citations in the report)**
</Output Format>

<Citation Rules>
- Assign each unique URL a single citation number in your text
- End with ### Sources that lists each source with corresponding numbers
- IMPORTANT: Number sources sequentially without gaps (1,2,3,4...) in the final list regardless of which sources you choose
- Example format:
  [1] Source Title: URL
  [2] Source Title: URL
</Citation Rules>

Critical Reminder: It is extremely important that any information that is even remotely relevant to the user's research topic is preserved verbatim (e.g. don't rewrite it, don't summarize it, don't paraphrase it).
"""

As the module name suggests, since the research results have already been summarized, this module is instructed to perform only compression, not summarization.

Following the prompt, the instructions repeatedly emphasize "the purpose of this step is just to remove obviously irrelevant or duplicative information" and "it is crucial that you don't lose any information from the raw messages."

Since compress_research connects to the END node, the research subgraph processing ends here. Research subgraphs run in parallel for each topic, with their report results flowing back to the supervisor.

The supervisor then reviews each report, determines whether the research is sufficient for the final answer, triggers additional research cycles if insufficient, and moves to final_report_generation when satisfied.

final_report_generation

This is the final report output step. It consolidates the reports from all researchers into a final report.

async def final_report_generation(state: AgentState, config: RunnableConfig):
    """Generate the final comprehensive research report with retry logic for token limits."""
    # Step 1: Extract research findings
    notes = state.get("notes", [])
    cleared_state = {"notes": {"type": "override", "value": []}}
    findings = "\n".join(notes)

    # Step 2: Configure the final report generation model
    configurable = Configuration.from_runnable_config(config)
    writer_model_config = {
        "model": configurable.final_report_model,
        "max_tokens": configurable.final_report_model_max_tokens,
        "api_key": get_api_key_for_model(configurable.final_report_model, config),
        "tags": ["langsmith:nostream"]
    }

    # Step 3: Attempt report generation with token limit retry logic
    max_retries = 3
    current_retry = 0
    findings_token_limit = None

    while current_retry <= max_retries:
        try:
            final_report_prompt = final_report_generation_prompt.format(
                research_brief=state.get("research_brief", ""),
                messages=get_buffer_string(state.get("messages", [])),
                findings=findings,
                date=get_today_str()
            )

            final_report = await configurable_model.with_config(writer_model_config).ainvoke([
                HumanMessage(content=final_report_prompt)
            ])

            return {
                "final_report": final_report.content,
                "messages": [final_report],
                **cleared_state
            }

        except Exception as e:
            if is_token_limit_exceeded(e, configurable.final_report_model):
                current_retry += 1
                if current_retry == 1:
                    model_token_limit = get_model_token_limit(configurable.final_report_model)
                    findings_token_limit = model_token_limit * 4
                else:
                    findings_token_limit = int(findings_token_limit * 0.9)
                findings = findings[:findings_token_limit]
                continue
            else:
                return {
                    "final_report": f"Error generating final report: {e}",
                    "messages": [AIMessage(content="Report generation failed due to an error")],
                    **cleared_state
                }

    return {
        "final_report": "Error generating final report: Maximum retries exceeded",
        "messages": [AIMessage(content="Report generation failed after maximum retries")],
        **cleared_state
    }

The report generation prompt:

final_report_generation_prompt = """Based on all the research conducted, create a comprehensive, well-structured answer to the overall research brief:
<Research Brief>
{research_brief}
</Research Brief>

For more context, here is all of the messages so far. Focus on the research brief above, but consider these messages as well for more context.
<Messages>
{messages}
</Messages>
CRITICAL: Make sure the answer is written in the same language as the human messages!

Today's date is {date}.

Here are the findings from the research that you conducted:
<Findings>
{findings}
</Findings>

Please create a detailed answer to the overall research brief that:
1. Is well-organized with proper headings (# for title, ## for sections, ### for subsections)
2. Includes specific facts and insights from the research
3. References relevant sources using [Title](URL) format
4. Provides a balanced, thorough analysis
5. Includes a "Sources" section at the end with all referenced links

<Citation Rules>
- Assign each unique URL a single citation number in your text
- End with ### Sources that lists each source with corresponding numbers
- IMPORTANT: Number sources sequentially without gaps (1,2,3,4...)
- Each source should be a separate line item in a list
- Example format:
  [1] Source Title: URL
  [2] Source Title: URL
- Citations are extremely important. Make sure to include these, and pay a lot of attention to getting these right.
</Citation Rules>
"""

Two important points stand out here.

First, the final report is generated in a single LLM call (one-shot). Since the final report can be quite long, you might think it would be faster to generate sections in parallel and then merge them. However, in practice this proves extremely difficult.

I have personally tried this in past projects, and the problem is that when calling the LLM independently for each section, each call produces its own "flavor" in terms of tone, sentence length, structural granularity, and so on. When combined, the result has overlapping sections, abrupt topic transitions, and an overall sense of unnaturalness.

This is analogous to the common experience of splitting a presentation on a single theme across multiple people: Person A handles pages 1-5, Person B handles pages 6-10, Person C handles pages 11-15, and so on. When merged, each section may be individually correct, but the whole feels disjointed.

Just as a specific person ultimately needs to write the final version for consistency, the LLM should generate reports in one shot. This repository also initially experimented with section-by-section generation but concluded, as documented in their blog posts, that the final output should be one-shot for consistency.

Second, how you modify this prompt determines the output quality. Since this is a general-purpose open-source repository, the report generation instructions are quite generic and unbiased. With vague queries and these generic instructions, the output will be a "60-70 point" report.

This is not necessarily bad. The quality is not low per se, but you risk generating reports where users merely skim the listed information and think "huh, okay."

Without increasing the resolution of which output format best suits the use case, what information is impactful for the user, and what is needed right now, you end up producing reports nobody reads.

For an internal AI search agent, looking at the prompt content for this output module should reveal whether the agent is genuinely well-used or whether it was deployed but largely ignored in practice.

Whether the output is crafted with a clear understanding of the business context and specific personas, or whether it remains generic and inoffensive, simply reusing APIs and open source as-is will only yield "decent" reports.

While the name "Deep Research" draws attention to research depth, the most important factor is not how to build sophisticated search logic but the fundamental sharpening of the core issue.

5. Design Points Summary

Based on the content covered so far, let's organize the key design principles. While Deep Research was our subject matter, these points are applicable to virtually all AI application design.

User Confirmation

In this repository, a module for confirming research content with the user (clarify_with_user_instruction) is included at the very beginning.

Even when simply saying "search," countless discussion points arise: what to search, how to search, what response time is acceptable, what output format to use, and so on. Rather than immediately starting execution, this module handles detailed confirmation with the user.

This "user confirmation" is arguably the most critical design point in AI application development, and many failure cases can be traced back to poor design in this area.

Whether the purpose, scope, and output expectations are aligned before the AI begins its task determines more than half the outcome. This is because there is no absolute standard of quality; whether output quality is high or low is determined by the user's expectations.

A meticulously crafted report would be considered low quality by a user who just wants a quick overview, while a neatly summarized key-points report would feel shallow and low quality to a user who wants to dive deep.

In other words, no matter how much effort you put into implementation, it is meaningless without first increasing the resolution of actual use cases and personas.

Given the importance of alignment, there are three possible approaches, with approach 3 being the goal:

  1. Require users to input detailed prompts (+ educate them on prompt engineering)
  2. Make the confirmation module thorough, asking users detailed questions
  3. Minimize the need for user confirmation altogether

Approach 1 is technically correct but results in low adoption. No matter how much the development team insists "the problem is users' sloppy prompt input," it will not move things forward because nobody actually wants to type extensive prompts.

Approach 2 does not expect much user input, instead drawing out information through guided questioning and dropdown selections. In terms of this repository, this means making clarify_with_user_instruction more robust. However, this is also not user-friendly, as the detailed questioning ultimately creates a burden comparable to approach 1.

One characteristic of excellent subordinates is their ability to anticipate intent without being told everything explicitly, acting proactively. This is the ideal to aim for. The design focus should not be on extracting as much information as possible from users, but on deeply understanding the user's context and pre-configuring as much as possible.

Understanding the business, organizational structure, department usage patterns, and timing of use, you can achieve higher resolution. For example: "Typically, the past 3 years is sufficient. International cases are actually unnecessary; what's needed is deep insight into regions A, B, and C where we have offices. Results should be summarized in about 5 pages including diagrams for mobile reference while on the move." Preparing several pattern variations for different personas such as frontline staff, management, and executives is also a viable approach.

Since user affiliation information is typically available internally, automatically detecting role and department to branch processing could also be an effective design.

As a general-purpose open-source tool, clarify_with_user_instruction naturally remains quite generic. For practical use, the key is how thin you can make this module (ideally skipping it entirely).

Data Source Specification

While this is a more search-specific topic, the quality of search results is ultimately determined by the quality of the data sources themselves. While Deep Research conjures images of broad web searches, searching a few reliable sites often produces higher quality output than broadly searching miscellaneous websites.

Improving data source quality before refining search logic is an extremely cost-effective measure. This is especially true when there are specialized sites rich with information relevant to your business or organization.

When prioritizing facts, IR materials and papers should be referenced while SNS should be prohibited. Conversely, when trend analysis is the main objective, the prompt should instruct prioritizing SNS investigation.

Narrowing data sources also provides significant benefits in response speed and token consumption (cost). Whether effective data sources can be specified should be considered from the earliest design stages.

Model Selection

Rather than using the same LLM model throughout the entire process, selecting different models for different purposes can improve response performance, reduce token consumption, and enhance output quality.

OpenDeepResearch allows configuration of four different models: (1) for decision-making including research planning, instructions, and completion determination, (2) for summarizing web search responses, (3) for compressing research results on specific topics, and (4) for generating the final report.

For example, in this case, a reasoning model for (1), a high-quality model for (4), and lightweight mini models for (2) and (3) (prioritizing response speed and cost reduction) could be considered.

Conversely, using a mini model for (4) final report generation could result in a simplistic final report no matter how well the preceding steps perform (unless simplicity is the goal, in which case a mini model is appropriate).

This is admittedly an area where endless tuning is possible, and hypotheses often do not hold. Building a configuration-switching script to test combinations systematically may be the most practical approach.

In particular, when report generation costs are too high or processing is too slow, investigating whether lightweight models can be used at any stage is worthwhile.

Summary and Compression

The aspect I personally found most educational was the summarization and compression design.

While the importance of context engineering is now widely recognized, this repository is filled with mechanisms to prevent context bloat.

The design ensures that unnecessary context information is never accumulated: web search results are summarized before being returned, research agent findings are compressed to remove duplicates before returning to the supervisor, and rather than accumulating messages in a single graph, graph states are separated so unnecessary information is not passed between phases.

Given that AI agent processing is becoming increasingly complex and longer, it will be important to consciously ask at each step: "Is unnecessary information being stacked in the context?"

StructuredOutput

When you want the LLM to output specific information in its response, it is common to include "also output X" in the system prompt.

For search results, you want organized summaries, evidence, and citations returned. However, system prompt instructions have no enforcement power, and the LLM occasionally "slacks off."

Therefore, in this repository, when summarizing search results with the LLM, the summary and its supporting evidence are output as separate fields:

class Summary(BaseModel):
    """Research summary with key findings."""
    summary: str
    key_excerpts: str

The response fields are then combined and stacked in the context. While instructing the system prompt to include evidence in the summary is one approach, for information that absolutely cannot be omitted, explicitly separating the output fields like this is recommended.

This is analogous to survey forms for humans: with a single free-text field, respondents may or may not include the information you need. But when you separate input fields for must-have information, those fields will not be left blank.

Since extracting necessary sections from LLM-generated free text is inherently unreliable, proactively separating required output fields is advisable for output enforcement, downstream processing stability, and maintainability.

Reflection

In this case, the think_tool serves as the introspection mechanism. As research tasks grow longer, context pollution accumulates and investigations can diverge, causing the original purpose to be forgotten. To counter this, think_tool is provided as a tool with no actual processing, serving as a deliberate pause point.

Including this only in the initial system prompt would be too weak. By providing it as a tool, the LLM is constantly presented with the option to reflect, and intermediate results are organized and stacked as fresh context.

For example, when delegating a research task to a subordinate, rather than having them research for an entire week and compile results on the last day, having them reflect on each day's findings and plan the next day's approach prevents significant divergence in the final results.

The longer the processing chain, the greater the risk that intermediate deviations compound and amplify. Explicitly designing reflection steps is likely to become increasingly important.

While model selection nuances tend to emerge naturally during development, reflection is something that will not occur to you during design unless you already know about it. This is a concept worth consciously keeping in mind.

One-Shot Final Output

A universally important principle in report generation is that while search and research processing can be parallelized, the final report must always be generated in one shot.

Since final report generation produces substantial output, the wait time is inevitably long. The temptation is to generate sections in parallel and merge them for speed. However, as I experienced in a past project, this approach ultimately does not work well.

Reports have forward and backward dependencies, and if the output tone and overall granularity are not consistent, the result is very difficult to read. When sections are generated independently, they tend to have ignored cross-references, content overlaps, inconsistent number formatting, and other misalignments.

Think of the common experience of splitting a final presentation on a single theme across multiple people (Person A: pages 1-5, Person B: pages 6-10, Person C: pages 11-15). When merged, despite each section being individually correct, the whole feels disjointed.

While completely separate reports would be fine, the composition of written text is inherently precise, with important dependencies and consistency requirements. One-shot output has thus become the best practice.

As a side note, this repository also initially experimented with parallel section-by-section output but ultimately adopted one-shot generation. The lesson learned, documented in their blog, is the importance of identifying which parts should be parallelized and which absolutely should not.

6. Conclusion

Having read through the entire repository, my impression is that this is truly in the domain of context engineering. With the prompt content, state design, and graph separation, the number of design variables is enormous. In the absence of any absolute correct answer, the designer's philosophy is strongly and distinctly reflected. This is precisely why context engineering is described as an art.

Conversely, the design skill of bridging what AI can do with the value an organization wants to create is what is being tested. This is arguably the most interesting challenge, and it is where product managers and architects can truly demonstrate their expertise.

While I went through the entire source code, it consists of only about 10 files and the volume itself is not overwhelming. It serves as an excellent study in LangGraph and agent design essentials, and I highly recommend it. However, there are points throughout that can be confusing (where the intent is not immediately clear), so please refer to the module explanations in Section 4 as needed.

I hope this article helps raise your resolution on AI agents, even if just a little. We also support deep-research AI agent adoption.

DI Agent Service
Autofusion Service DI Agent Deep-research AI agents that work across internal and external information Learn more