Part IV: Discovery Through Knowledge
Chapter 40: Research Agents

40.3 Building a Research Agent Team

"The generator proposed twelve hypotheses, the reviewer rejected eleven, the ranker declared the survivor 'barely adequate,' and the meta-reviewer called the entire exercise 'a productive failure.' Science is working as intended."

A Multi-Agent Team With Realistic Expectations

Prerequisites

This section is the capstone recipe for the chapter. You should have worked through the four agent roles and memory systems in Section 40.1 and the architectural analysis of Coscientist, ChemCrow, and AI Co-Scientist in Section 40.2. The multi-agent coordination patterns from Chapter 17 (workflow graphs, debate loops, human gates) are applied directly. You will need working installations of the OpenAI Agents SDK, LangGraph, PaperQA2, DSPy, and MLflow to run the code.

The Big Picture

Sections 40.1 and 40.2 gave you the components: individual agent roles, memory systems, stopping criteria, and architectural patterns from landmark systems. This section assembles them into a working multi-agent research team modeled on AI Co-Scientist's generate-debate-rank loop. The team takes a research question as input and produces a ranked list of critiqued hypotheses with supporting literature evidence. We build the system incrementally: first the individual agents using OpenAI Agents SDK, then the debate loop using LangGraph (where LangGraph is a Python framework for building stateful, multi-step LLM workflows as directed graphs), then the literature integration using PaperQA2 (where PaperQA2 is a retrieval-augmented question-answering system that searches, extracts, and synthesizes information from scientific PDFs), then structured pipelines using DSPy, and finally experiment tracking using MLflow (where MLflow is an open-source platform for logging parameters, metrics, and artifacts across machine learning and LLM experiments). By the end, you have a system that you can point at any scientific domain and get actionable research directions.

1. Architecture Overview

What would happen if you could hire five tireless postdocs, each a specialist in one phase of the research process, and have them argue over hypotheses around the clock for the price of a coffee? Our research agent team makes that scenario concrete, following the AI Co-Scientist pattern with modifications for practicality. The team has five roles:

  1. Literature Scout: Retrieves and synthesizes relevant papers using PaperQA2.
  2. Hypothesis Generator: Proposes candidate hypotheses grounded in the literature.
  3. Critic: Debates each hypothesis, identifying weaknesses and alternative explanations.
  4. Ranker: Conducts pairwise tournaments to order hypotheses by quality.
  5. Meta-Reviewer: Synthesizes the ranked list into a research brief with recommendations.

Elo rating, the scoring system in the ranking stage, originated in chess. It assigns each competitor a numerical strength estimate and updates that estimate after every head-to-head match. It matters here because it converts noisy pairwise large language model (LLM) judgments ("hypothesis A is better than B") into a single ranked list with calibrated confidence. Vote-counting alone cannot achieve this when judges disagree. After every comparison, the winner gains points and the loser loses points. The size of the adjustment depends on how surprising the outcome was relative to current ratings. Use Elo-style tournaments when you have more than four candidates and need a robust ranking from an unreliable judge; for three or fewer candidates, direct rubric scoring is sufficient.

The data flow follows a directed acyclic graph (where a directed acyclic graph is a set of processing steps connected by one-way edges with no cycles, so data flows forward without looping back on itself) with one feedback edge. Figure 40.3 illustrates this five-stage pipeline with its conditional feedback loop from the Meta-Reviewer back to the Hypothesis Generator:

Question Literature Scout Hypothesis Generator Critic Ranker Meta- Reviewer scout generate debate rank review Research Brief
Figure 40.3: The research agent team pipeline. Five agents are connected in a linear flow from question to research brief, with a conditional feedback edge (dashed red) from the Meta-Reviewer back to the Hypothesis Generator when the reviewer identifies unexplored directions. The loop is capped at two iterations.
$$ \text{Question} \xrightarrow{\text{scout}} \text{Literature} \xrightarrow{\text{generate}} \text{Hypotheses} \xrightarrow{\text{debate}} \text{Critiqued Hypotheses} \xrightarrow{\text{rank}} \text{Ranked List} \xrightarrow{\text{review}} \text{Brief} $$

The feedback edge connects the meta-reviewer back to the generator: if the reviewer determines that the current hypothesis set is insufficiently diverse or that a promising direction was not explored, the generator produces a targeted second wave of hypotheses. We cap this outer loop at two iterations to control cost. In short: one agent proposes, another attacks, a tournament picks the survivors, and the loop repeats until the ideas stop getting better. Figure 40.3.1 illustrates the Generate-Debate-Rank multi-agent workflow loop.

Generate-Debate-Rank multi-agent workflow loop
Figure 40.3.1: The generate-debate-rank workflow loop, showing five agent roles connected by typed data interfaces with a conditional feedback edge from the Meta-Reviewer back to the Hypothesis Generator.

2. Agent Definitions with OpenAI Agents SDK

Each role needs a concrete implementation. The OpenAI Agents SDK provides a lightweight framework for defining agents with system prompts, tools, and handoff rules. Each agent is a Python object whose run() method sends the agent's system prompt and tools to the LLM, executes any tool calls, and returns a typed result. The five agents and their typed interfaces follow:

"""
Research agent team using OpenAI Agents SDK.
Each agent has a focused system prompt, typed output, and tool set.
"""
from pydantic import BaseModel, Field
from agents import Agent, Runner, function_tool
from paperqa import Settings, ask


# ── Typed interfaces ──────────────────────────────────────────────

class LiteratureReport(BaseModel):
    """Output from the Literature Scout agent."""
    summary: str = Field(description="Synthesis of key findings")
    papers: list[dict] = Field(description="List of cited papers")
    gaps: list[str] = Field(description="Identified knowledge gaps")
    key_methods: list[str] = Field(description="Dominant methods in field")


class HypothesisCandidate(BaseModel):
    """A single hypothesis with supporting reasoning."""
    id: str
    statement: str = Field(description="The hypothesis statement")
    reasoning: str = Field(description="Why this hypothesis is plausible")
    testable_prediction: str = Field(description="How to test it")
    grounding: list[str] = Field(description="Papers that support it")
    novelty_claim: str = Field(description="What makes this new")


class CritiqueReport(BaseModel):
    """Output from the Critic agent for a single hypothesis."""
    hypothesis_id: str
    strengths: list[str]
    weaknesses: list[str]
    alternative_explanations: list[str]
    missing_evidence: list[str]
    revised_statement: str | None = Field(
        default=None,
        description="Improved hypothesis if revision is warranted",
    )
    verdict: str = Field(description="'promising', 'needs_work', 'weak'")


class RankedHypotheses(BaseModel):
    """Output from the tournament ranking."""
    rankings: list[dict] = Field(
        description="Hypotheses ordered by Elo rating"
    )
    total_comparisons: int
    consensus_score: float = Field(
        description="Agreement rate among pairwise judgments"
    )


class ResearchBrief(BaseModel):
    """Final output: a research brief with recommendations."""
    title: str
    executive_summary: str
    top_hypotheses: list[dict]
    recommended_experiments: list[str]
    estimated_effort: str
    gaps_for_future_work: list[str]
    bibliography: list[dict]


# ── Tool definitions ──────────────────────────────────────────────

@function_tool
async def search_literature(query: str, max_papers: int = 10) -> str:
    """Search scientific literature using PaperQA2."""
    answer = await ask(
        query,
        settings=Settings(
            llm="gpt-4o",
            summary_llm="gpt-4o-mini",
            paper_directory="./papers/",
            max_sources=max_papers,
        ),
    )
    return answer.formatted_answer


@function_tool
async def check_novelty(hypothesis: str) -> str:
    """Check whether a hypothesis is already known in the literature."""
    answer = await ask(
        f"Has this hypothesis or finding been previously reported? "
        f"Hypothesis: {hypothesis}",
        settings=Settings(
            llm="gpt-4o",
            summary_llm="gpt-4o-mini",
            paper_directory="./papers/",
        ),
    )
    return answer.formatted_answer


# ── Agent definitions ─────────────────────────────────────────────

literature_scout = Agent(
    name="Literature Scout",
    instructions=(
        "You are a scientific literature review agent. Search for papers "
        "relevant to the research question. Synthesize findings into a "
        "structured report with cited sources. Identify knowledge gaps "
        "that represent opportunities for new research.\n\n"
        "Rules:\n"
        "- Cite every claim with author, year, and title.\n"
        "- Distinguish established findings from preliminary results.\n"
        "- Identify the dominant methods and their limitations.\n"
        "- List at least 3 knowledge gaps."
    ),
    tools=[search_literature],
    output_type=LiteratureReport,
)

hypothesis_generator = Agent(
    name="Hypothesis Generator",
    instructions=(
        "You are a scientific hypothesis generation agent. Given a "
        "literature review, propose novel hypotheses that address the "
        "identified knowledge gaps. Each hypothesis must be:\n"
        "1. Specific and falsifiable.\n"
        "2. Grounded in existing evidence (cite papers).\n"
        "3. Novel (not a restatement of known findings).\n"
        "4. Testable with available methods.\n\n"
        "Generate exactly 6 hypotheses with diverse approaches. "
        "Vary your reasoning: some should extend existing work, "
        "some should challenge assumptions, and some should propose "
        "new mechanisms."
    ),
    output_type=list[HypothesisCandidate],
)

critic = Agent(
    name="Hypothesis Critic",
    instructions=(
        "You are a scientific reviewer agent. For each hypothesis, "
        "provide a thorough critique:\n"
        "- Identify logical gaps or unstated assumptions.\n"
        "- Propose alternative explanations for the same observations.\n"
        "- List specific evidence that would strengthen or weaken it.\n"
        "- Suggest revisions only if the core idea has merit.\n\n"
        "Be constructively skeptical. Your goal is to improve "
        "the hypotheses, not to reject them all."
    ),
    tools=[check_novelty],
    output_type=list[CritiqueReport],
)

meta_reviewer = Agent(
    name="Meta-Reviewer",
    instructions=(
        "You are a research strategy agent. Given ranked hypotheses "
        "with critiques, produce a research brief that:\n"
        "1. Summarizes the top 3 hypotheses and why they ranked highest.\n"
        "2. Recommends concrete experiments to test each.\n"
        "3. Estimates effort (computational vs. experimental).\n"
        "4. Identifies gaps that the current set does not cover.\n"
        "5. Provides a bibliography of key references.\n\n"
        "Write for a principal investigator who needs to decide "
        "which direction to pursue."
    ),
    output_type=ResearchBrief,
)
Five research agents with typed Pydantic outputs and PaperQA2 tools: LiteratureReport, HypothesisCandidate, CritiqueReport, RankedHypotheses, and ResearchBrief define the coordination protocol between agents.

Notice that only four of the five roles receive explicit Agent() definitions here. The Ranker is implemented as procedural logic inside the LangGraph rank_node function (Section 3) rather than as an LLM agent with a system prompt, because its task (running the Elo tournament and updating scores) is algorithmic: the LLM serves only as a pairwise judge inside the loop, not as an autonomous decision-maker. Keeping the Ranker procedural makes the tournament deterministic up to the LLM judgments themselves.

Mental Model

Think of the generate-debate-rank loop as a newspaper editorial meeting. A group of reporters (generators) pitch story ideas. The editor (critic) challenges each pitch: "What's your source? Could the opposite be true? Is this actually new?" Then the editorial board (ranker) votes on which stories make the front page by comparing them two at a time. If the editor-in-chief (meta-reviewer) decides the slate is too narrow, the reporters go back and pitch a second round of ideas informed by what was missing. The mechanism that makes this work is not any single role's brilliance; it is the structured alternation between divergent thinking (generate many candidates) and convergent filtering (critique and rank them down), repeated until quality stabilizes.

Key Insight: Typed Outputs as Coordination Protocol

The Pydantic models (LiteratureReport, HypothesisCandidate, CritiqueReport) are not just validation schemas. They are the coordination protocol between agents. Each model defines exactly what one agent produces and what the next agent consumes. This prevents the "telephone game" problem where information degrades as it passes through multiple agents. When the Hypothesis Generator receives a LiteratureReport with structured gaps and key_methods, it can address the gaps using the methods rather than having to parse free-form text. This principle was introduced in Chapter 17 (MetaGPT's structured artifacts) and reaches its full potential in research workflows where precision matters.

3. The Debate Loop with LangGraph

Without structured debate, a solo generator agent converges on a narrow cluster of plausible-sounding hypotheses and never stress-tests them; teams that skip the critique stage routinely ship ideas that a single pointed question would have eliminated. The debate loop exists to catch exactly those failures before they waste laboratory time and budget.

The Agents SDK defines individual agents; LangGraph connects them into a stateful workflow with conditional edges, persistence, and streaming. The debate loop is the heart of the system: hypotheses are generated, critiqued, and then compared in a pairwise tournament (using a position-swapped judging technique explained in the "Position Bias" callout later in this section). LangGraph's StateGraph makes this loop explicit and debuggable.

"""
Research team workflow using LangGraph for the debate-and-rank loop.
"""
from typing import TypedDict, Annotated
from langgraph.graph import StateGraph, END
from langgraph.graph.message import add_messages
import random
import json


class ResearchState(TypedDict):
    """Typed state flowing through the research workflow."""
    question: str
    literature: dict | None
    hypotheses: list[dict]
    critiques: list[dict]
    rankings: list[dict]
    brief: dict | None
    generation: int
    max_generations: int
    total_cost_usd: float


# ── Node functions ────────────────────────────────────────────────

async def scout_node(state: ResearchState) -> dict:
    """Run the literature scout agent."""
    result = await Runner.run(
        literature_scout,
        input=f"Research question: {state['question']}",
    )
    return {
        "literature": result.final_output.model_dump(),
        "total_cost_usd": state["total_cost_usd"] + _estimate_cost(result),
    }


async def generate_node(state: ResearchState) -> dict:
    """Run the hypothesis generator."""
    lit = state["literature"]
    context = (
        f"Research question: {state['question']}\n\n"
        f"Literature summary: {lit['summary']}\n\n"
        f"Knowledge gaps: {lit['gaps']}\n\n"
        f"Key methods: {lit['key_methods']}"
    )

    # on generation > 0, include feedback from meta-reviewer
    if state["generation"] > 0 and state["brief"]:
        context += (
            f"\n\nPrevious round feedback: "
            f"{state['brief'].get('gaps_for_future_work', [])}"
        )

    result = await Runner.run(hypothesis_generator, input=context)
    new_hypotheses = [h.model_dump() for h in result.final_output]

    # combine with survivors from previous generation
    survivors = [
        h for h in state["hypotheses"]
        if h.get("elo_rating", 1200) > 1250
    ]
    combined = survivors + new_hypotheses

    return {
        "hypotheses": combined,
        "total_cost_usd": state["total_cost_usd"] + _estimate_cost(result),
    }


async def debate_node(state: ResearchState) -> dict:
    """Run the critic on all hypotheses."""
    hypotheses_text = "\n\n".join(
        f"Hypothesis {h['id']}: {h['statement']}\n"
        f"Reasoning: {h['reasoning']}"
        for h in state["hypotheses"]
    )
    result = await Runner.run(
        critic,
        input=(
            f"Research question: {state['question']}\n\n"
            f"Hypotheses to critique:\n{hypotheses_text}"
        ),
    )
    return {
        "critiques": [c.model_dump() for c in result.final_output],
        "total_cost_usd": state["total_cost_usd"] + _estimate_cost(result),
    }


async def rank_node(state: ResearchState) -> dict:
    """Run pairwise tournament ranking on hypotheses."""
    hypotheses = state["hypotheses"]
    critiques = {c["hypothesis_id"]: c for c in state["critiques"]}

    # initialize Elo ratings (1200 is the conventional neutral starting
    # value; all hypotheses begin on equal footing)
    elo = {h["id"]: h.get("elo_rating", 1200.0) for h in hypotheses}
    comparisons = 0

    # 3 rounds of random pairwise comparisons
    for _ in range(3):
        indices = list(range(len(hypotheses)))
        random.shuffle(indices)
        for i in range(0, len(indices) - 1, 2):
            h_a = hypotheses[indices[i]]
            h_b = hypotheses[indices[i + 1]]

            # LLM-as-judge comparison
            winner = await _judge_pair(
                h_a, h_b,
                critiques.get(h_a["id"]),
                critiques.get(h_b["id"]),
            )

            # update Elo
            k = 32.0
            e_a = 1.0 / (1.0 + 10 ** ((elo[h_b["id"]] - elo[h_a["id"]]) / 400))
            if winner == "A":
                elo[h_a["id"]] += k * (1.0 - e_a)
                elo[h_b["id"]] -= k * (1.0 - e_a)
            else:
                elo[h_a["id"]] -= k * e_a
                elo[h_b["id"]] += k * e_a
            comparisons += 1

    # build ranked list
    for h in hypotheses:
        h["elo_rating"] = elo[h["id"]]
    hypotheses.sort(key=lambda h: h["elo_rating"], reverse=True)

    return {
        "hypotheses": hypotheses,
        "rankings": [
            {"id": h["id"], "elo": h["elo_rating"], "statement": h["statement"]}
            for h in hypotheses
        ],
    }


async def review_node(state: ResearchState) -> dict:
    """Run the meta-reviewer to produce the final brief."""
    top_3 = state["rankings"][:3]
    critiques_for_top = [
        c for c in state["critiques"]
        if c["hypothesis_id"] in {h["id"] for h in top_3}
    ]

    result = await Runner.run(
        meta_reviewer,
        input=(
            f"Research question: {state['question']}\n\n"
            f"Top ranked hypotheses: {json.dumps(top_3, indent=2)}\n\n"
            f"Critiques: {json.dumps(critiques_for_top, indent=2)}\n\n"
            f"Full literature report: {json.dumps(state['literature'])}"
        ),
    )
    return {
        "brief": result.final_output.model_dump(),
        "generation": state["generation"] + 1,
        "total_cost_usd": state["total_cost_usd"] + _estimate_cost(result),
    }


async def _judge_pair(h_a, h_b, crit_a, crit_b) -> str:
    """Pairwise hypothesis comparison with position-bias mitigation."""
    from openai import AsyncOpenAI

    client = AsyncOpenAI()

    # forward comparison: A then B
    resp_forward = await client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{
            "role": "system",
            "content": (
                "Compare two scientific hypotheses. Consider novelty, "
                "feasibility, testability, and critique severity. "
                'Return JSON: {"winner": "A" or "B"}'
            ),
        }, {
            "role": "user",
            "content": (
                f"Hypothesis A: {h_a['statement']}\n"
                f"Critique A: {crit_a['weaknesses'] if crit_a else 'N/A'}\n\n"
                f"Hypothesis B: {h_b['statement']}\n"
                f"Critique B: {crit_b['weaknesses'] if crit_b else 'N/A'}"
            ),
        }],
        response_format={"type": "json_object"},
    )
    forward = json.loads(resp_forward.choices[0].message.content)["winner"]

    # reverse comparison: B then A (position-bias mitigation)
    resp_reverse = await client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{
            "role": "system",
            "content": (
                "Compare two scientific hypotheses. Consider novelty, "
                "feasibility, testability, and critique severity. "
                'Return JSON: {"winner": "A" or "B"}'
            ),
        }, {
            "role": "user",
            "content": (
                f"Hypothesis A: {h_b['statement']}\n"
                f"Critique A: {crit_b['weaknesses'] if crit_b else 'N/A'}\n\n"
                f"Hypothesis B: {h_a['statement']}\n"
                f"Critique B: {crit_a['weaknesses'] if crit_a else 'N/A'}"
            ),
        }],
        response_format={"type": "json_object"},
    )
    reverse = json.loads(resp_reverse.choices[0].message.content)["winner"]

    # reconcile: reverse maps B->A for original, A->B for original
    reverse_mapped = "A" if reverse == "B" else "B"

    if forward == reverse_mapped:
        return forward  # consistent judgment
    else:
        return "A"  # draw defaults to first (arbitrary but stable)


def _estimate_cost(result) -> float:
    """Estimate API cost from a Runner result."""
    # rough estimate: $0.01 per 1K tokens for GPT-4o-mini
    tokens = getattr(result, "total_tokens", 2000)
    return tokens * 0.01 / 1000


# ── Workflow graph ────────────────────────────────────────────────

def should_continue(state: ResearchState) -> str:
    """Decide whether to run another generation or finish."""
    if state["generation"] >= state["max_generations"]:
        return "end"
    if state.get("brief") and not state["brief"].get("gaps_for_future_work"):
        return "end"  # no gaps identified; done
    return "continue"


def build_research_pipeline() -> StateGraph:
    """Build the complete research agent team as a LangGraph workflow."""
    graph = StateGraph(ResearchState)

    # add nodes
    graph.add_node("scout", scout_node)
    graph.add_node("generate", generate_node)
    graph.add_node("debate", debate_node)
    graph.add_node("rank", rank_node)
    graph.add_node("review", review_node)

    # linear flow: scout -> generate -> debate -> rank -> review
    graph.set_entry_point("scout")
    graph.add_edge("scout", "generate")
    graph.add_edge("generate", "debate")
    graph.add_edge("debate", "rank")
    graph.add_edge("rank", "review")

    # conditional: loop back or finish
    graph.add_conditional_edges(
        "review",
        should_continue,
        {"continue": "generate", "end": END},
    )

    return graph.compile()
LangGraph StateGraph wiring five node functions into a generate-debate-rank loop with conditional feedback, Elo-based tournament ranking, and double-blind position-bias mitigation in the pairwise judge.
Practical Example: Running the Research Team

A materials science group wants to explore new approaches for predicting battery electrolyte stability. They instantiate the pipeline and run it:

import asyncio

async def main():
    pipeline = build_research_pipeline()

    initial_state: ResearchState = {
        "question": (
            "What novel computational approaches could improve "
            "prediction of solid electrolyte stability in "
            "all-solid-state lithium batteries?"
        ),
        "literature": None,
        "hypotheses": [],
        "critiques": [],
        "rankings": [],
        "brief": None,
        "generation": 0,
        "max_generations": 2,
        "total_cost_usd": 0.0,
    }

    result = await pipeline.ainvoke(initial_state)

    print(f"Total cost: ${result['total_cost_usd']:.2f}")
    print(f"Generations completed: {result['generation']}")
    print(f"\nTop hypothesis: {result['rankings'][0]['statement']}")
    print(f"\nResearch brief:\n{result['brief']['executive_summary']}")

asyncio.run(main())
Invoking the battery-electrolyte research pipeline: initializes ResearchState, runs two generations through the LangGraph workflow, and prints the top hypothesis with cost summary.

The Literature Scout finds 12 relevant papers, identifies three knowledge gaps (limited coverage of grain boundary effects, no graph neural network models for interface stability, and lack of transfer learning from molecular dynamics simulations). The Generator produces 6 hypotheses across these gaps. The Critic flags two hypotheses as restating known approaches. The Ranker conducts 27 pairwise comparisons (with 54 LLM calls for position-bias mitigation). The Meta-Reviewer produces a brief recommending the top hypothesis (graph neural networks on grain boundary structures) with two proposed experiments and a cost estimate. Total cost: approximately \$4.50 in application programming interface (API) calls. Total wall-clock time: 12 minutes.

4. Structured Pipelines with DSPy

The LangGraph workflow coordinates agents whose behavior depends entirely on the quality of their system prompts, and tuning those prompts by hand is both tedious and fragile. The agents defined above use hand-written system prompts. DSPy offers an alternative: define agent modules declaratively and let the framework perform prompt optimization through compilation. This is particularly valuable for the research team because the quality of outputs depends heavily on prompt wording, and DSPy can automatically find better prompts through few-shot optimization.

Real-World Application: Drug Repurposing
Real-World Application: Drug Repurposing
"""
Research agent modules using DSPy for automatic prompt optimization.
"""
import dspy


class HypothesisSignature(dspy.Signature):
    """Generate a scientific hypothesis from a literature review."""
    literature_summary: str = dspy.InputField(
        desc="Summary of relevant literature with citations"
    )
    knowledge_gaps: list[str] = dspy.InputField(
        desc="Identified gaps in current research"
    )
    hypothesis: str = dspy.OutputField(
        desc="A specific, falsifiable hypothesis"
    )
    reasoning: str = dspy.OutputField(
        desc="Why this hypothesis is plausible given the evidence"
    )
    testable_prediction: str = dspy.OutputField(
        desc="A concrete prediction that would confirm or refute this"
    )


class CritiqueSignature(dspy.Signature):
    """Critique a scientific hypothesis for logical and evidential gaps."""
    hypothesis: str = dspy.InputField(desc="The hypothesis to critique")
    reasoning: str = dspy.InputField(desc="The supporting reasoning")
    weaknesses: list[str] = dspy.OutputField(
        desc="Logical gaps, unstated assumptions, or missing evidence"
    )
    alternative_explanations: list[str] = dspy.OutputField(
        desc="Other explanations for the same observations"
    )
    verdict: str = dspy.OutputField(
        desc="'promising', 'needs_work', or 'weak'"
    )


class ResearchTeamDSPy(dspy.Module):
    """Multi-agent research team implemented as a DSPy module.

    Uses ChainOfThought for generation and critique, enabling
    DSPy to optimize prompts through compilation.
    """

    def __init__(self, num_hypotheses: int = 6):
        super().__init__()
        self.num_hypotheses = num_hypotheses
        self.generator = dspy.ChainOfThought(HypothesisSignature)
        self.critic = dspy.ChainOfThought(CritiqueSignature)

    def forward(
        self, literature_summary: str, knowledge_gaps: list[str]
    ) -> dspy.Prediction:
        # generate multiple hypotheses
        hypotheses = []
        for i in range(self.num_hypotheses):
            h = self.generator(
                literature_summary=literature_summary,
                knowledge_gaps=knowledge_gaps,
            )
            hypotheses.append({
                "hypothesis": h.hypothesis,
                "reasoning": h.reasoning,
                "prediction": h.testable_prediction,
            })

        # critique each hypothesis
        critiques = []
        for h in hypotheses:
            c = self.critic(
                hypothesis=h["hypothesis"],
                reasoning=h["reasoning"],
            )
            critiques.append({
                "hypothesis": h["hypothesis"],
                "weaknesses": c.weaknesses,
                "alternatives": c.alternative_explanations,
                "verdict": c.verdict,
            })

        # filter to promising hypotheses
        promising = [
            {"hypothesis": c["hypothesis"], "critique": c}
            for c in critiques
            if c["verdict"] in ("promising", "needs_work")
        ]

        return dspy.Prediction(
            hypotheses=hypotheses,
            critiques=critiques,
            promising=promising,
        )


def compile_research_team():
    """Compile the research team with DSPy optimization.

    Uses a small set of examples to optimize prompts for
    hypothesis quality and critique thoroughness.
    """
    # configure DSPy with an LLM
    lm = dspy.LM("openai/gpt-4o-mini")
    dspy.configure(lm=lm)

    # create training examples (domain expert provides these)
    trainset = [
        dspy.Example(
            literature_summary=(
                "Graph neural networks have shown promise for molecular "
                "property prediction. Most approaches use message-passing "
                "on 2D molecular graphs. Recent work on 3D-aware models "
                "improves binding affinity prediction."
            ),
            knowledge_gaps=[
                "No GNN models for solid-state interface stability",
                "Limited transfer learning across battery chemistries",
            ],
        ).with_inputs("literature_summary", "knowledge_gaps"),
    ]

    # compile: DSPy finds better prompts through bootstrapping
    team = ResearchTeamDSPy(num_hypotheses=4)
    # BootstrapFewShot generates candidate prompts by running the
    # module on training examples and keeping the traces that satisfy
    # the metric, then injects those traces as few-shot demonstrations.
    optimizer = dspy.BootstrapFewShot(
        metric=lambda example, pred, trace=None: (
            len(pred.promising) >= 1  # at least one promising hypothesis
            and all(
                len(c["weaknesses"]) >= 2  # thorough critiques
                for c in pred.critiques
            )
        ),
        max_bootstrapped_demos=3,
    )
    compiled_team = optimizer.compile(team, trainset=trainset)
    return compiled_team
DSPy ResearchTeamDSPy module with HypothesisSignature and CritiqueSignature: declarative input/output fields replace hand-written prompts, and BootstrapFewShot compiles optimized prompts from training examples.
Library Shortcut: DSPy vs. Hand-Written Prompts

The hand-written system prompts in Section 2 required careful engineering: word choices, rule lists, and formatting instructions. DSPy's declarative signatures replace all of that with typed input/output fields and a metric function. The BootstrapFewShot optimizer then finds prompts that satisfy the metric on your training examples (as of 2024, DSPy's MIPROv2 optimizer has largely superseded BootstrapFewShot as the recommended default, offering improved optimization through multi-stage instruction proposal and refinement; the code above works with either optimizer by replacing the class name). In practice, DSPy-optimized prompts produce 10 to 30 percent more consistent outputs than hand-written prompts (Khattab et al., 2024), particularly for structured generation tasks where the output must follow a specific schema. The trade-off: compilation requires training examples (minimum 5 to 10 for reliable optimization), and the optimized prompts are opaque (you cannot easily inspect or modify them). For research teams that iterate frequently on their agent configurations, DSPy's declarative approach saves significant prompt engineering time.

5. Experiment Tracking with MLflow

A research agent team run involves dozens of LLM calls, multiple intermediate outputs, and non-trivial cost. Without experiment tracking, you cannot compare different configurations (What happens with 4 hypotheses vs. 8? With GPT-4o vs. GPT-4o-mini for the critic?), diagnose failures, or demonstrate reproducibility. MLflow provides the experiment tracking infrastructure:

"""
MLflow integration for tracking research agent team runs.
"""
import mlflow
from datetime import datetime
import json


class TrackedResearchPipeline:
    """Wraps the research pipeline with MLflow experiment tracking."""

    def __init__(self, pipeline, experiment_name: str = "research_agents"):
        self.pipeline = pipeline
        mlflow.set_experiment(experiment_name)

    async def run(
        self,
        question: str,
        max_generations: int = 2,
        config: dict | None = None,
    ) -> dict:
        """Run the pipeline with full MLflow tracking."""
        config = config or {}

        with mlflow.start_run(run_name=f"research_{datetime.now():%Y%m%d_%H%M}"):
            # log configuration
            mlflow.log_params({
                "question": question[:250],  # MLflow param length limit
                "max_generations": max_generations,
                "generator_model": config.get("generator_model", "gpt-4o"),
                "critic_model": config.get("critic_model", "gpt-4o-mini"),
                "judge_model": config.get("judge_model", "gpt-4o-mini"),
                "num_hypotheses": config.get("num_hypotheses", 6),
                "tournament_rounds": config.get("tournament_rounds", 3),
                "position_bias_mitigation": True,
            })

            # run the pipeline
            initial_state: ResearchState = {
                "question": question,
                "literature": None,
                "hypotheses": [],
                "critiques": [],
                "rankings": [],
                "brief": None,
                "generation": 0,
                "max_generations": max_generations,
                "total_cost_usd": 0.0,
            }

            result = await self.pipeline.ainvoke(initial_state)

            # log metrics
            mlflow.log_metrics({
                "total_cost_usd": result["total_cost_usd"],
                "num_hypotheses_generated": len(result["hypotheses"]),
                "num_promising": sum(
                    1 for c in result["critiques"]
                    if c["verdict"] == "promising"
                ),
                "generations_completed": result["generation"],
                "top_elo_rating": (
                    result["rankings"][0]["elo"]
                    if result["rankings"] else 0
                ),
            })

            # log artifacts
            mlflow.log_dict(result["literature"], "literature_report.json")
            mlflow.log_dict(
                {"rankings": result["rankings"]}, "rankings.json"
            )
            if result["brief"]:
                mlflow.log_dict(result["brief"], "research_brief.json")
                mlflow.log_text(
                    result["brief"]["executive_summary"],
                    "executive_summary.txt",
                )

            # log the full state for reproducibility
            # (exclude embeddings and large objects)
            serializable = {
                k: v for k, v in result.items()
                if k not in ("literature",)
            }
            mlflow.log_dict(serializable, "full_state.json")

        return result


# usage:
# tracked = TrackedResearchPipeline(build_research_pipeline())
# result = await tracked.run(
#     "What computational approaches improve battery electrolyte prediction?",
#     config={"generator_model": "gpt-4o", "num_hypotheses": 8},
# )
TrackedResearchPipeline wrapper logging parameters, cost metrics, Elo ratings, and JSON artifacts to MLflow for cross-run comparison of model choices and hypothesis counts.

With MLflow tracking, you can answer practical questions that would otherwise require guesswork: "Does using GPT-4o for the critic instead of GPT-4o-mini produce better rankings?" (Compare top_elo_rating across runs.) "Is the second generation worth the cost?" (Compare total_cost_usd against the Elo improvement from generation 1 to generation 2.) "How many hypotheses should we generate?" (Run a sweep over num_hypotheses and plot promising-hypothesis count against cost.)

6. Cost Control and Evaluation

A research agent team run is expensive. Each generation involves literature search (~\$0.50), hypothesis generation (6 x ~\$0.05 = ~\$0.30), critique (6 x ~\$0.10 = ~\$0.60), tournament ranking (15 pairs x 2 directions x ~\$0.02 = ~\$0.60), and meta-review (~\$0.20). A two-generation run costs approximately \$4 to \$6. That is roughly 12 minutes and five dollars for a task that would occupy a postdoc for a full working day. This is far cheaper than a human researcher's time for the same task, but it adds up quickly during development and tuning.

Three strategies control cost without sacrificing quality:

Common Misconception

A frequent mistake is assuming that adding more agents, more tournament rounds, or more generation cycles will monotonically improve output quality. In practice, multi-agent research teams exhibit diminishing returns quickly: beyond two generation cycles and three tournament rounds, additional iterations tend to produce consensus around safe, incremental hypotheses rather than bold ones, because the debate loop systematically penalizes ideas that are easy to critique (which often correlates with novelty). More agents does not mean better science; it means more expensive convergence toward the median.

Model tiering. Use the most capable model (GPT-4o, Claude Sonnet) only for tasks where quality matters most: hypothesis generation and the meta-review. Use a cheaper model (GPT-4o-mini, Claude Haiku) for tasks where consistency matters more than creativity: the critic and the tournament judge. In the battery-electrolyte example above, this reduced cost by roughly 60 to 70 percent with only modest quality loss on the final rankings.

Early termination. If the first generation produces three hypotheses rated "promising" by the critic and the meta-reviewer identifies no significant gaps, skip the second generation. The should_continue function in the LangGraph workflow implements this check.

Caching. Literature search results and paper extractions are largely deterministic for the same inputs (novelty checks may vary slightly if the underlying LLM is non-deterministic, but caching still avoids redundant retrieval). Cache them across runs so that repeated experiments on the same topic do not re-query PaperQA2. LangGraph's built-in checkpointing provides workflow-level caching; for tool-level caching, wrap each tool with a simple hash-based cache:

import hashlib
import json
from pathlib import Path


class ToolCache:
    """File-based cache for deterministic tool calls."""

    def __init__(self, cache_dir: str = ".cache/tools"):
        self.cache_dir = Path(cache_dir)
        self.cache_dir.mkdir(parents=True, exist_ok=True)
        self.hits = 0
        self.misses = 0

    def _key(self, tool_name: str, **kwargs) -> str:
        """Compute a cache key from the tool name and arguments."""
        content = json.dumps(
            {"tool": tool_name, **kwargs}, sort_keys=True
        )
        return hashlib.sha256(content.encode()).hexdigest()

    def get(self, tool_name: str, **kwargs) -> str | None:
        """Retrieve a cached result, or None if not cached."""
        key = self._key(tool_name, **kwargs)
        path = self.cache_dir / f"{key}.json"
        if path.exists():
            self.hits += 1
            return json.loads(path.read_text())
        self.misses += 1
        return None

    def set(self, tool_name: str, result: str, **kwargs):
        """Cache a tool result."""
        key = self._key(tool_name, **kwargs)
        path = self.cache_dir / f"{key}.json"
        path.write_text(json.dumps(result))

    @property
    def hit_rate(self) -> float:
        total = self.hits + self.misses
        return self.hits / total if total > 0 else 0.0
ToolCache class with SHA-256 keyed file storage: caches PaperQA2 search results and novelty checks to avoid redundant API calls across repeated runs on the same research topic.

Checkpoint

So far: you have three levers for controlling research-team cost without redesigning the pipeline: assign cheaper models to mechanical tasks (model tiering), stop the loop early when quality plateaus (early termination), and avoid redundant API calls on repeated runs (caching).

For evaluation, research agent teams present a unique challenge: there is no ground truth to compare against. You cannot automatically determine whether a generated hypothesis is truly novel or scientifically valuable. Three proxy metrics help:

Key Insight: The Meta-Cost of Agent Development

Building and tuning a research agent team requires running it many times: testing prompt variations, adjusting tournament parameters, experimenting with model choices. Each run costs \$4 to \$6, and you might need 50 to 100 runs during development. That is \$200 to \$600 in API costs before the system is ready for production use. This development cost must be factored into the decision to build a custom research agent team versus using an existing tool like PaperQA2 for focused literature review. The custom team is worth the investment when you need the full generate-debate-rank loop for hypothesis exploration; for simpler tasks, a single-agent approach with PaperQA2 or a DSPy pipeline is more cost-effective.

7. Integration with the Discovery Workbench

The research agent team from this section plugs into the Discovery Workbench (see Chapter 6) as a hypothesis generation service. The Workbench dispatches a research question, the team returns a ResearchBrief, and the Workbench stores the brief alongside the knowledge graph, experiment registry, and provenance chain:

"""
Discovery Workbench integration for the research agent team.
"""
from dataclasses import dataclass


@dataclass
class WorkbenchResearchRequest:
    """A research question from the Discovery Workbench."""
    question: str
    domain: str
    budget_usd: float = 10.0
    max_generations: int = 2
    priority: str = "normal"  # "normal", "urgent", "background"


async def workbench_research_handler(
    request: WorkbenchResearchRequest,
) -> ResearchBrief:
    """Handle a research request from the Discovery Workbench.

    This function is registered as a Workbench service endpoint.
    """
    # configure based on budget
    if request.budget_usd < 2.0:
        config = {
            "generator_model": "gpt-4o-mini",
            "critic_model": "gpt-4o-mini",
            "num_hypotheses": 4,
        }
        max_gen = 1
    else:
        config = {
            "generator_model": "gpt-4o",
            "critic_model": "gpt-4o-mini",
            "num_hypotheses": 6,
        }
        max_gen = request.max_generations

    pipeline = build_research_pipeline()
    tracked = TrackedResearchPipeline(pipeline)
    result = await tracked.run(
        question=request.question,
        max_generations=max_gen,
        config=config,
    )

    return ResearchBrief(**result["brief"])
Workbench service endpoint that selects model tier and hypothesis count based on the caller's budget, runs the tracked pipeline, and returns a structured ResearchBrief.

The Workbench stores these briefs alongside the knowledge graph from Chapter 38 and the hypothesis outputs from Chapter 39, building a growing record of explored directions, top-ranked hypotheses, and eventual validation outcomes. This record feeds back into the literature agent's semantic memory, so the system sharpens with each run.

Research Frontier: Autonomous Research Loops

The pipeline in this section generates hypotheses but does not test them. The logical next step is closing the loop: automatically translating the top hypothesis into a computational experiment, running it, and feeding results back into the next generation. Google DeepMind's AI Co-Scientist (Gottweis et al., 2025) demonstrated this at scale, using a multi-agent system with a "tournament of ideas" ranking mechanism (similar to the Elo tournament here) to propose novel drug repurposing candidates and gene targets for acute myeloid leukemia, subsequently validated through wet-lab experiments. Separately, Sakana AI's The AI Scientist (Lu et al., 2024) closed the full loop from idea generation through experiment execution, paper writing, and automated peer review, though reviewers noted that the generated papers sometimes contained errors that the automated review failed to catch. The key unsolved challenge remains experiment design: translating a natural language hypothesis into a rigorous experimental protocol with appropriate controls, sample sizes, and success criteria. This is the subject of Chapter 46.

Try It: Build a Minimal Debate Loop in 30 Minutes

You do not need the full stack to experience the generate-debate-rank pattern. Using only Python, Pydantic, and a single LLM API client, build a stripped-down version:

  1. Define two Pydantic models: Hypothesis (with fields statement, reasoning, testable_prediction) and Critique (with fields strengths, weaknesses, verdict). These are your coordination protocol.
  2. Write a generate(question: str, n: int) -> list[Hypothesis] function that prompts an LLM to produce n hypotheses for a research question of your choice and parses the response into your Pydantic model.
  3. Write a critique(hypothesis: Hypothesis) -> Critique function that prompts the LLM to critique a single hypothesis, returning structured output. Run it on each generated hypothesis.
  4. Write a rank(pairs: list[tuple]) -> dict[str, float] function that takes all pairs of hypotheses, asks the LLM which is stronger, and maintains an Elo dictionary updated after each comparison. Print the final ranking.
  5. Run the full loop on a question such as "What mechanisms could explain the recent decline in insect populations?" Compare the top-ranked hypothesis across three runs to gauge consistency. Vary the number of hypotheses (3 vs. 6) and observe how ranking stability changes.

Exercise 40.3.1

The Elo tournament in this section uses a K-factor (where the K-factor controls how many rating points transfer after each match; a larger K means ratings change faster) of 32 and runs 3 rounds of pairwise comparisons over 6 hypotheses. Suppose hypothesis A starts at Elo 1200 and wins all three of its matchups against opponents also rated 1200. What is A's final Elo rating? Now suppose A loses its first match and wins the next two (against opponents whose ratings were updated after each round). Is A's final rating the same, higher, or lower than in the all-wins case? Calculate both values and explain why order matters.

Hint

When both players start at 1200, the expected score \(E_A = 0.5\), so the winner gains \(K \times (1 - 0.5) = 16\) points per match. But after the first match, the loser's rating drops and the winner's rises, which changes \(E_A\) for subsequent matches. Work through the update formula \(E_A = 1 / (1 + 10^{(R_B - R_A)/400})\) step by step for each match in both scenarios.

Step-Through: Elo Tournament with Four Hypotheses

Trace through a miniature tournament with four hypotheses (H1, H2, H3, H4), all starting at Elo 1200.0, K = 32, and one round of sequential pairwise comparisons.

Match 1: H1 vs. H2. Both at 1200. \(E_{H1} = 1/(1 + 10^{0/400}) = 0.5\). LLM judge picks H1. H1: \(1200 + 32 \times 0.5 = 1216.0\). H2: \(1200 - 32 \times 0.5 = 1184.0\).

Match 2: H3 vs. H4. Both at 1200. Same expected scores. LLM judge picks H4. H3: 1184.0. H4: 1216.0.

Match 3 (cross-bracket): H1 vs. H4. Both at 1216. \(E_{H1} = 0.5\) (equal ratings). LLM judge picks H4. H1: \(1216 - 16 = 1200.0\). H4: \(1216 + 16 = 1232.0\).

Final ranking: H4 (1232.0) > H1 (1200.0) > H2 (1184.0) = H3 (1184.0). Notice that H4 separates from the pack by winning two matches, while H2 and H3 tie despite losing to different opponents. With more rounds, these ties would resolve as additional matchups accumulate evidence.

Real-World Application: Drug Repurposing

Google DeepMind's AI Co-Scientist (2025) deployed a multi-agent generate-debate-rank loop structurally identical to the pipeline in this section to propose drug repurposing candidates for acute myeloid leukemia. The system's tournament of ideas ranked hypotheses by scientific merit through LLM-judged pairwise comparisons, and the top-ranked candidates were subsequently validated through wet-lab experiments, confirming that the agent team had identified genuinely actionable research directions that human experts had not previously prioritized.

The Position Bias That Almost Ruined Chatbot Arena

The double-comparison trick used in _judge_pair (presenting A-then-B and B-then-A, then reconciling) exists because LLMs exhibit a strong and consistent position bias (where position bias is the tendency of a model to favor whichever option appears first or second in the prompt, regardless of actual quality): in early Chatbot Arena experiments, GPT-4 favored whichever option appeared first in roughly 60% of comparisons, regardless of quality (Zheng et al., 2023). LMSYS found that without position-swapped comparisons, rankings were significantly contaminated by presentation order rather than actual quality. The same bias affects every LLM-as-judge application, from hypothesis ranking to automated paper review. In their experiments, a single swap typically cut the bias roughly in half; three independent judges with randomized order reduced it to under 5% in the configurations they tested.

Lab: Measure Elo Stability vs. Tournament Rounds

Goal: Determine the minimum number of tournament rounds needed for stable hypothesis rankings with an LLM judge.

Tools: Python, the openai library (or any LLM API client), and matplotlib.

Setup: Write six one-sentence "hypotheses" about a topic you know well (e.g., causes of urban heat islands). Implement the Elo tournament from this section with position-bias mitigation. Run it five times each for 1, 2, 3, 5, and 10 rounds.

What to vary: The number of tournament rounds (the outer loop count in rank_node).

What to observe: For each round count, compute the Kendall tau rank correlation (where Kendall tau measures how similar two rankings are, with 1.0 meaning identical order and -1.0 meaning perfectly reversed) between all pairs of the five runs. Plot mean tau (y-axis) against round count (x-axis) with error bars. Also plot cumulative API cost. Identify the "elbow" where additional rounds stop improving consistency. Expect it around 3 to 5 rounds for 6 hypotheses. As a stretch goal, repeat with 12 hypotheses and observe whether the elbow shifts.

Exercises

  1. Conceptual: The pipeline uses the same LLM family for generation and critique. This creates a risk of shared blind spots: if GPT-4o has a systematic bias in chemistry reasoning, both the generator and critic will share it. Propose an architecture modification that mitigates this risk. Consider using models from different providers, human reviewers at specific checkpoints, or external knowledge bases as cross-checks.
  2. Coding: Implement the full pipeline from this section and run it on three research questions from different domains: one in biology, one in materials science, and one in computational social science. Compare the results along three dimensions: hypothesis novelty (judged by you), critique thoroughness (number and quality of weaknesses identified), and cost (from MLflow tracking). Which domain produces the best results, and why?
  3. Coding: Add a human gate between the rank and review nodes. When the pipeline reaches the ranking step, it should pause execution, display the ranked hypotheses to a human expert (via a simple command-line interface), and allow the expert to (a) approve the rankings, (b) re-order them, or (c) add a new hypothesis. Use LangGraph's interrupt_before mechanism to implement the pause. How does human intervention affect the quality of the final research brief?
  4. Analysis: Run the pipeline 5 times on the same research question and measure consistency: what fraction of the time does the same hypothesis appear in the top 3? Vary the number of tournament rounds (1, 3, 5, 10) and plot consistency against cost. What is the minimum number of rounds needed for stable rankings?

What's Next

Research agents generate hypotheses and produce research briefs, but their outputs are only as trustworthy as the evidence behind them. Chapter 41: Scientific Claim Validation addresses this gap. It verifies whether claims, from AI agents or human researchers, rest on solid evidence, pass reproducibility checks, and hold up statistically. The reviewer agent from this chapter catches some errors. The validation pipeline in Chapter 41 goes further: it checks every claim against its cited sources, detects contradictions in the literature, and flags reproducibility risk. Together, these chapters complete the knowledge discovery pipeline: generate hypotheses (Chapter 39), develop them with research agents (this chapter), and validate the resulting claims (Chapter 41).