Part IV: Discovery Through Knowledge
Chapter 40: Research Agents

40.1 Literature, Coding, and Analysis Agents

"I have read 4,000 papers, synthesized 12 review articles, and still cannot decide whether to use a t-test or a Mann-Whitney U."

A Literature Agent With Analysis Envy

Prerequisites

This section opens the chapter. You should be comfortable with the retrieval-augmented generation (RAG) pipeline from Chapter 37 (retrieval, reranking, generation with citations), the hypothesis generation patterns from Chapter 39, and the agent loop (observe, think, act, repeat) from Chapter 10. Familiarity with knowledge graph construction from Chapter 38 helps for understanding how semantic memory is structured.

The Big Picture

A human researcher cycles through four cognitive modes every working day: reading the literature, writing code to process data, running analyses to test hypotheses, and reviewing the results for errors and next steps. Each mode demands different skills, different tools, and a different mental frame. An AI system that tries to do all four in a single prompt quickly runs into context window limits, conflicting objectives, and degraded output quality. The solution mirrors what we learned in Chapter 17: factor the research workflow into specialized agents, each with a focused system prompt, a curated tool set, and a typed interface for communicating with its peers. This section defines the four canonical research agent roles, equips them with memory systems that persist across sessions, and uses optimal stopping theory to decide when they should stop gathering information and start acting.

1. The Four Research Agent Roles

In a single afternoon, a researcher hunting for a new antibiotic lead will scan thirty papers, write a clustering script, run a statistical test on the resulting groups, and then pause to ask whether she picked the right test in the first place. These four cognitive modes recur in every research workflow, from a weekend survey to a multi-year discovery campaign, and each maps to a specialized agent class:

Without specialized agents, research teams routinely miss critical papers, re-run failed experiments they forgot about, or apply the wrong statistical test because the same overloaded prompt handles reading, coding, and analysis at once. Structuring these roles as distinct agents eliminates entire categories of such errors.

A research agent is a large language model (LLM) wrapped in a control loop. This loop grants access to external tools (search APIs, code sandboxes, statistical libraries) and persistent state, so the agent can carry out multi-step scientific tasks autonomously. Research agents matter because modern science produces far more literature, data, and candidate hypotheses than any human team can process manually. A single agent can search thousands of papers, run hundreds of experiment configurations, or screen millions of molecules in the time a researcher spends on a handful. The mechanism is the observe-think-act loop. The agent observes its current state (papers found, results obtained), reasons about what to do next (refine a query, run a test, flag an anomaly), executes an action through a tool call, and repeats until it meets a stopping criterion. Use research agents when the task involves high-volume information processing with domain-specific tool use; for single-turn question answering without tool access, a standard LLM prompt suffices.

Literature agents search, retrieve, and synthesize published knowledge. They query application programming interfaces (APIs) (Semantic Scholar, PubMed, arXiv), download and parse full-text PDFs, extract key findings, and produce structured summaries with full citations. Their system prompt emphasizes accuracy, citation fidelity, and awareness of recency. Their tool set includes search APIs, PDF parsers, and citation managers. The RAG pipeline from Chapter 37 provides the retrieval backbone.

Figure 40.1 below illustrates how these four roles exchange typed data in a research workflow, with feedback loops that allow later stages to trigger re-examination by earlier ones.

Literature search, retrieve, cite Coding implement, execute Analysis test, interpret Reviewer critique, verify paper summaries code + data results
Figure 40.1: Information flow among the four research agent roles. Solid arrows show the forward pipeline (literature to coding to analysis to reviewer). Dashed arrows show feedback loops where the reviewer sends corrections back to earlier agents.

From Text to Code to Judgment

Coding agents translate research ideas into executable code. They write data processing pipelines, implement algorithms from papers, set up experiment configurations, and produce visualization scripts. Their system prompt emphasizes correctness, reproducibility, and adherence to coding standards. Their tool set includes code execution sandboxes, package managers, and version control. The vibe coding patterns from Chapter 9 apply directly.

Analysis agents run statistical tests, fit models, interpret results, and assess whether findings support or refute a hypothesis. They select appropriate statistical methods, check assumptions (normality, independence, sample size), compute effect sizes and confidence intervals, and flag potential confounders. Their system prompt emphasizes statistical rigor and honest reporting of uncertainty. Their tool set includes statistical libraries, plotting tools, and the Bayesian methods from Chapter 32.

Reviewer agents critique methodology, check for logical errors, identify missing controls, and assess whether conclusions follow from the evidence. They act as an internal peer reviewer, catching problems before findings are shared. Their system prompt emphasizes skepticism, thoroughness, and constructive feedback. Their tool set includes access to the literature agent's outputs (to verify claims against sources) and the analysis agent's results (to check statistical validity). In short: give each cognitive mode its own agent, its own tools, and its own memory, and the research loop polices itself. Figure 40.1.1 illustrates four research agent roles and their information flow.

Four research agent roles and their information flow
Figure 40.1.1: Information flow between the four research agent roles, showing the data types exchanged at each handoff and the shared three-layer memory system that persists across sessions.

Common Misconception

A common misconception is that you must deploy all four agent roles for every research task, and that more agents always produce better results. In practice, many tasks need only one or two roles: a quick literature survey needs a literature agent alone, and a data analysis task needs only the coding and analysis agents. Adding unnecessary agents increases latency, cost, and coordination overhead without improving quality. Start with the minimum set of roles your task requires, and add agents only when you observe specific failure modes (such as unchecked statistical assumptions) that a new role would address.

Key Insight: Role Separation Reduces Error Propagation

When a single agent both generates and reviews its own work, confirmation bias is structurally embedded: the same model that chose a statistical test is asked whether that test was appropriate. Separating generation from review creates an adversarial dynamic (a structure where one agent's role is to challenge another's output) that catches errors. Du et al. (2023) show that multi-agent debate improves factual accuracy by 10 to 20 percent on reasoning benchmarks. In research contexts, where a single uncaught error can invalidate an entire study, this separation is not a luxury; it is a safety measure.

2. Implementing a Literature Agent

A literature agent wraps the RAG pipeline from Chapter 37 in an agent loop that can plan multi-step searches, follow citation chains, and synthesize findings into structured reviews. The key architectural decision is how much autonomy to give the search planning: should the agent decide its own queries, or should it follow a pre-defined search protocol?

The answer depends on the task. For a targeted question ("What is the binding affinity of compound X to target Y?"), a pre-defined protocol works well. For an exploratory review ("What approaches have been tried for antibiotic resistance prediction?"), the agent needs autonomy to refine its queries based on what it finds. The following code demonstrates both patterns:

from dataclasses import dataclass, field
from typing import Any
import json


@dataclass
class LiteratureAgentState:
    """Typed state for the literature agent's session."""
    query: str
    papers_found: list[dict] = field(default_factory=list)
    papers_read: list[dict] = field(default_factory=list)
    synthesis: str = ""
    search_history: list[str] = field(default_factory=list)
    iteration: int = 0
    max_iterations: int = 10
    confidence: float = 0.0


class LiteratureAgent:
    """Agent that searches, retrieves, and synthesizes scientific literature."""

    SYSTEM_PROMPT = """You are a scientific literature agent. Your role is to
    find, read, and synthesize published research papers relevant to a query.

    Rules:
    - Always cite papers with authors, year, and title.
    - Distinguish between claims supported by evidence and speculation.
    - Report the number of papers searched and read.
    - When uncertain, say so explicitly and suggest follow-up queries.
    - Never fabricate citations or invent paper titles.
    """

    def __init__(self, llm_client, search_tools: dict[str, Any]):
        self.llm = llm_client
        self.tools = search_tools  # semantic_scholar, pubmed, arxiv, etc.

    async def search_papers(
        self, query: str, source: str = "semantic_scholar", limit: int = 20
    ) -> list[dict]:
        """Search for papers using the specified source API."""
        search_fn = self.tools[source]
        results = await search_fn(query=query, limit=limit)
        return [
            {
                "paper_id": r["paperId"],
                "title": r["title"],
                "authors": [a["name"] for a in r.get("authors", [])],
                "year": r.get("year"),
                "abstract": r.get("abstract", ""),
                "citation_count": r.get("citationCount", 0),
                "source": source,
            }
            for r in results
        ]

    async def read_paper(self, paper: dict) -> dict:
        """Retrieve and extract key information from a paper."""
        full_text = await self.tools["pdf_reader"](paper["paper_id"])
        extraction = await self.llm.chat(
            system="Extract: (1) main finding, (2) method, "
                   "(3) key results with numbers, (4) limitations.",
            user=f"Paper: {paper['title']}\n\n{full_text[:8000]}",
        )
        return {
            **paper,
            "extraction": extraction.content,
            "read": True,
        }

    async def should_continue_searching(
        self, state: LiteratureAgentState
    ) -> bool:
        """Decide whether to search more or synthesize.

        Uses a simple confidence threshold: stop when the agent
        believes it has sufficient coverage of the topic.
        """
        if state.iteration >= state.max_iterations:
            return False
        if len(state.papers_read) < 3:
            return True  # always read at least 3 papers

        assessment = await self.llm.chat(
            system="Assess search completeness. Return JSON: "
                   '{"confidence": 0.0-1.0, "reason": "...", '
                   '"suggested_query": "..." or null}',
            user=(
                f"Original query: {state.query}\n"
                f"Papers read: {len(state.papers_read)}\n"
                f"Key findings so far: {state.synthesis[:2000]}\n"
                f"Search queries tried: {state.search_history}"
            ),
        )
        result = json.loads(assessment.content)
        state.confidence = result["confidence"]
        return result["confidence"] < 0.8 and result["suggested_query"] is not None

    async def run(self, query: str) -> LiteratureAgentState:
        """Execute the full literature agent loop."""
        state = LiteratureAgentState(query=query)

        while await self.should_continue_searching(state):
            # search
            current_query = query if state.iteration == 0 else (
                await self._refine_query(state)
            )
            state.search_history.append(current_query)
            papers = await self.search_papers(current_query)
            state.papers_found.extend(papers)

            # read top papers (by citation count, deduplicated)
            seen_ids = {p["paper_id"] for p in state.papers_read}
            new_papers = [
                p for p in papers if p["paper_id"] not in seen_ids
            ]
            new_papers.sort(key=lambda p: p["citation_count"], reverse=True)

            for paper in new_papers[:5]:
                read_result = await self.read_paper(paper)
                state.papers_read.append(read_result)

            # update running synthesis
            state.synthesis = await self._synthesize(state)
            state.iteration += 1

        return state

    async def _refine_query(self, state: LiteratureAgentState) -> str:
        """Generate a refined search query based on findings so far."""
        response = await self.llm.chat(
            system="Generate a follow-up search query to fill gaps.",
            user=(
                f"Original: {state.query}\n"
                f"Previous queries: {state.search_history}\n"
                f"Current synthesis: {state.synthesis[:1000]}"
            ),
        )
        return response.content.strip()

    async def _synthesize(self, state: LiteratureAgentState) -> str:
        """Produce a running synthesis of all papers read."""
        paper_summaries = "\n\n".join(
            f"- {p['title']} ({p['year']}): {p['extraction']}"
            for p in state.papers_read
        )
        response = await self.llm.chat(
            system=self.SYSTEM_PROMPT,
            user=(
                f"Query: {state.query}\n\n"
                f"Papers reviewed:\n{paper_summaries}\n\n"
                "Synthesize the findings into a coherent summary "
                "with citations."
            ),
        )
        return response.content
A literature agent with adaptive search: it refines queries based on gaps in its running synthesis and stops when confidence reaches 0.8 or the iteration budget is exhausted.
Practical Example: Surveying Antibiotic Resistance Prediction

A pharmaceutical team wants to understand the landscape of machine learning (ML) approaches for predicting antibiotic resistance from genomic data. They instantiate a LiteratureAgent with Semantic Scholar and PubMed search tools and the query "machine learning antibiotic resistance prediction genomic." The agent's first search yields 20 papers. It reads the top 5 by citation count, synthesizes findings (random forests on single-nucleotide polymorphism (SNP) features dominate pre-2020; transformer models on raw sequences dominate post-2022), identifies a gap (no coverage of graph neural networks on resistance gene networks), refines its query to "graph neural network antimicrobial resistance gene," and finds 8 additional papers. After 4 iterations and 18 papers read, it reaches 0.85 confidence and produces a structured review with citation-backed claims, a timeline of methodological advances, and three identified gaps. Total cost: approximately \$2.50 in API calls and 6 minutes of wall-clock time.

3. Coding and Analysis Agents

While the literature agent operates on text, the coding and analysis agents operate on data and code. The coding agent translates a research plan into executable experiments; the analysis agent interprets the results.

A coding agent for research differs from a general-purpose coding agent (see Chapter 16) in three ways. First, it must produce reproducible code: fixed random seeds, pinned dependency versions, and logged configurations. Second, it must handle scientific data formats such as HDF5 (hierarchical binary data), FITS (astronomy images), SDF/MOL (molecular structures), and FASTA (biological sequences). Third, it must integrate with experiment tracking: every run logs parameters, metrics, and artifacts to a system like MLflow, an open-source platform for tracking experiment runs, parameters, and outputs (see Chapter 22).

from dataclasses import dataclass
from typing import Any


@dataclass
class ExperimentSpec:
    """Specification for a computational experiment."""
    name: str
    hypothesis: str
    data_sources: list[str]
    method: str
    parameters: dict[str, Any]
    expected_output: str
    success_criteria: str


class CodingAgent:
    """Agent that writes and executes reproducible experiment code."""

    SYSTEM_PROMPT = """You are a scientific coding agent. You write Python code
    to run computational experiments.

    Rules:
    - Always set random seeds for reproducibility.
    - Log all parameters and results to MLflow.
    - Include error handling for data loading and computation.
    - Write clear docstrings explaining what each function does.
    - Pin library versions in requirements.
    - Never modify raw data files; write results to separate outputs.
    """

    def __init__(self, llm_client, sandbox, mlflow_client):
        self.llm = llm_client
        self.sandbox = sandbox        # isolated code execution
        self.mlflow = mlflow_client

    async def generate_experiment_code(
        self, spec: ExperimentSpec
    ) -> str:
        """Generate experiment code from a specification."""
        response = await self.llm.chat(
            system=self.SYSTEM_PROMPT,
            user=(
                f"Write a complete Python script for this experiment:\n"
                f"Name: {spec.name}\n"
                f"Hypothesis: {spec.hypothesis}\n"
                f"Data: {spec.data_sources}\n"
                f"Method: {spec.method}\n"
                f"Parameters: {spec.parameters}\n"
                f"Expected output: {spec.expected_output}\n"
                f"Success criteria: {spec.success_criteria}\n\n"
                "Include MLflow logging with mlflow.log_param() and "
                "mlflow.log_metric(). Set random seed 42."
            ),
        )
        return response.content

    async def run_experiment(
        self, spec: ExperimentSpec
    ) -> dict[str, Any]:
        """Generate, validate, and execute experiment code."""
        code = await self.generate_experiment_code(spec)

        # static validation: check for common issues
        validation = await self._validate_code(code)
        if not validation["valid"]:
            code = await self._fix_code(code, validation["issues"])

        # execute in sandbox
        with self.mlflow.start_run(run_name=spec.name):
            self.mlflow.log_params(spec.parameters)
            result = await self.sandbox.execute(code, timeout=300)

            if result.exit_code != 0:
                return {
                    "status": "error",
                    "error": result.stderr,
                    "code": code,
                }

            self.mlflow.log_metric("exit_code", result.exit_code)
            return {
                "status": "success",
                "stdout": result.stdout,
                "artifacts": result.files,
                "code": code,
                "mlflow_run_id": self.mlflow.active_run().info.run_id,
            }

    async def _validate_code(self, code: str) -> dict:
        """Check code for common research pitfalls."""
        checks = {
            "has_random_seed": "random.seed" in code or "np.random.seed" in code
                               or "torch.manual_seed" in code,
            "has_mlflow_logging": "mlflow.log" in code,
            "no_raw_data_mutation": "inplace=True" not in code,
            "has_error_handling": "try:" in code or "except" in code,
        }
        issues = [k for k, v in checks.items() if not v]
        return {"valid": len(issues) == 0, "issues": issues}

    async def _fix_code(self, code: str, issues: list[str]) -> str:
        """Fix identified issues in generated code."""
        response = await self.llm.chat(
            system="Fix the following issues in this experiment code.",
            user=f"Issues: {issues}\n\nCode:\n{code}",
        )
        return response.content
A coding agent that generates experiment scripts from typed specifications, validates them for reproducibility pitfalls (random seeds, MLflow logging, error handling), and executes them in a sandboxed environment.

The analysis agent receives the outputs of the coding agent (data frames, metrics, plots) and applies statistical reasoning:

@dataclass
class AnalysisResult:
    """Structured output from the analysis agent."""
    test_name: str
    test_statistic: float
    p_value: float
    effect_size: float | None
    confidence_interval: tuple[float, float] | None
    assumptions_met: dict[str, bool]
    interpretation: str
    recommendation: str  # "support", "reject", or "inconclusive"


class AnalysisAgent:
    """Agent that selects and runs appropriate statistical analyses."""

    SYSTEM_PROMPT = """You are a statistical analysis agent. You select
    appropriate tests, verify assumptions, and interpret results.

    Rules:
    - Always check test assumptions before running a test.
    - Report effect sizes, not just p-values.
    - Use confidence intervals alongside point estimates.
    - Distinguish statistical significance from practical significance.
    - When assumptions are violated, use non-parametric alternatives.
    - Never p-hack: pre-register the analysis plan before seeing results.
    """

    def __init__(self, llm_client):
        self.llm = llm_client

    async def select_test(
        self, hypothesis: str, data_description: str
    ) -> dict:
        """Select the appropriate statistical test for a hypothesis."""
        response = await self.llm.chat(
            system=self.SYSTEM_PROMPT,
            user=(
                f"Hypothesis: {hypothesis}\n"
                f"Data: {data_description}\n\n"
                "Select the appropriate statistical test. Return JSON:\n"
                '{"test": "name", "assumptions": ["list"], '
                '"parametric": true/false, '
                '"alternative_if_assumptions_fail": "name"}'
            ),
        )
        return json.loads(response.content)

    async def run_analysis(
        self,
        hypothesis: str,
        data_path: str,
        analysis_plan: dict | None = None,
    ) -> AnalysisResult:
        """Execute a complete analysis pipeline."""
        import pandas as pd
        from scipy import stats

        data = pd.read_csv(data_path)

        # select test if no plan provided
        if analysis_plan is None:
            data_desc = (
                f"Shape: {data.shape}, "
                f"Columns: {list(data.columns)}, "
                f"Types: {data.dtypes.to_dict()}"
            )
            analysis_plan = await self.select_test(hypothesis, data_desc)

        # check assumptions
        assumptions = await self._check_assumptions(
            data, analysis_plan["assumptions"]
        )

        # pick the test (parametric or fallback)
        test_name = analysis_plan["test"]
        if not all(assumptions.values()):
            test_name = analysis_plan.get(
                "alternative_if_assumptions_fail", test_name
            )

        # run the test
        stat, p_value = self._execute_test(test_name, data)

        # compute effect size
        effect_size = self._compute_effect_size(test_name, data)

        # interpret
        interpretation = await self._interpret(
            test_name, stat, p_value, effect_size, hypothesis
        )

        # p < 0.05 is the conventional statistical significance threshold;
        # effect_size > 0.2 corresponds to Cohen's "small" threshold,
        # ensuring the result is not just significant but meaningful.
        recommendation = (
            "support" if p_value < 0.05 and (effect_size or 0) > 0.2
            else "reject" if p_value > 0.1
            else "inconclusive"
        )

        return AnalysisResult(
            test_name=test_name,
            test_statistic=stat,
            p_value=p_value,
            effect_size=effect_size,
            confidence_interval=None,  # computed per test type
            assumptions_met=assumptions,
            interpretation=interpretation,
            recommendation=recommendation,
        )

    def _execute_test(
        self, test_name: str, data: "pd.DataFrame"
    ) -> tuple[float, float]:
        """Run a named statistical test on data."""
        from scipy import stats

        # dispatch to the appropriate scipy function
        test_registry = {
            "t_test_independent": lambda d: stats.ttest_ind(
                d.iloc[:, 0].dropna(), d.iloc[:, 1].dropna()
            ),
            "mann_whitney_u": lambda d: stats.mannwhitneyu(
                d.iloc[:, 0].dropna(), d.iloc[:, 1].dropna()
            ),
            "paired_t_test": lambda d: stats.ttest_rel(
                d.iloc[:, 0].dropna(), d.iloc[:, 1].dropna()
            ),
            "chi_squared": lambda d: stats.chi2_contingency(
                pd.crosstab(d.iloc[:, 0], d.iloc[:, 1])
            )[:2],
            "pearson_correlation": lambda d: stats.pearsonr(
                d.iloc[:, 0].dropna(), d.iloc[:, 1].dropna()
            ),
        }
        fn = test_registry.get(test_name)
        if fn is None:
            raise ValueError(f"Unknown test: {test_name}")
        result = fn(data)
        return float(result[0]), float(result[1])

    def _compute_effect_size(
        self, test_name: str, data: "pd.DataFrame"
    ) -> float | None:
        """Compute Cohen's d or equivalent effect size.

        Cohen's d measures the standardized difference between two group
        means: d = (mean1 - mean2) / pooled_std. Values of 0.2, 0.5, and
        0.8 are conventionally interpreted as small, medium, and large.
        """
        import numpy as np

        if test_name in ("t_test_independent", "paired_t_test"):
            g1 = data.iloc[:, 0].dropna()
            g2 = data.iloc[:, 1].dropna()
            pooled_std = np.sqrt(
                (g1.std() ** 2 + g2.std() ** 2) / 2
            )
            if pooled_std == 0:
                return None
            return float((g1.mean() - g2.mean()) / pooled_std)
        return None

    async def _check_assumptions(
        self, data: "pd.DataFrame", assumptions: list[str]
    ) -> dict[str, bool]:
        """Check statistical assumptions for the selected test."""
        from scipy import stats

        results = {}
        for assumption in assumptions:
            if assumption == "normality":
                col = data.iloc[:, 0].dropna()
                _, p = stats.shapiro(col[:5000])  # shapiro limit
                results["normality"] = p > 0.05
            elif assumption == "equal_variance":
                g1 = data.iloc[:, 0].dropna()
                g2 = data.iloc[:, 1].dropna()
                _, p = stats.levene(g1, g2)
                results["equal_variance"] = p > 0.05
            elif assumption == "independence":
                results["independence"] = True  # assumed by design
            else:
                results[assumption] = True  # default pass
        return results

    async def _interpret(
        self, test_name, stat, p_value, effect_size, hypothesis
    ) -> str:
        """Generate a natural language interpretation of results."""
        response = await self.llm.chat(
            system="Interpret statistical results in plain language.",
            user=(
                f"Test: {test_name}\n"
                f"Statistic: {stat:.4f}\n"
                f"P-value: {p_value:.4f}\n"
                f"Effect size: {effect_size}\n"
                f"Hypothesis: {hypothesis}\n\n"
                "Provide a clear interpretation."
            ),
        )
        return response.content
An analysis agent that selects statistical tests based on hypothesis type, checks assumptions (normality via Shapiro-Wilk, equal variance via Levene's test), falls back to non-parametric alternatives, and reports effect sizes alongside p-values.
Library Shortcut: PaperQA2 as a Literature Agent

The literature agent we built from scratch in ~120 lines is what PaperQA2 provides as a production-ready system. PaperQA2 handles PDF downloading, chunk-level retrieval, citation extraction, and multi-step question answering in about 10 lines of setup:

from paperqa import Settings, ask

answer = await ask(
    "What ML methods predict antibiotic resistance from genomics?",
    settings=Settings(
        llm="gpt-4o",
        summary_llm="gpt-4o-mini",
        paper_directory="./papers/",
    ),
)
print(answer.formatted_answer)  # cited synthesis
print(f"Sources: {len(answer.contexts)}")  # papers used
Using PaperQA2's ask() function to replace the full LiteratureAgent class with a single call that handles search, retrieval, extraction, and citation-backed synthesis.

PaperQA2 handles caching (papers downloaded once are reused), chunk-level citation tracking (every sentence maps to a specific passage in a specific paper), and confidence scoring (low-confidence answers are flagged). The line-count reduction is roughly 10x. The trade-off: PaperQA2 controls the retrieval strategy, which may not match your domain's needs (for example, chemistry papers with reaction schemas require different extraction than natural language processing (NLP) papers with tables of benchmark results).

4. Agent Memory: Episodic, Semantic, and Procedural

Building specialized agents solves the capability problem, but each session starts from scratch unless the agents can retain what they have learned. A research agent that forgets everything between sessions is like a postdoc who wakes up each morning with no memory of yesterday's experiments. Effective research agents need persistent memory that accumulates across sessions. Following the cognitive science taxonomy used by Park et al. (2023) in their Generative Agents paper, which simulated believable human behavior using LLM-based agents with structured memory, we distinguish three types:

Episodic memory stores what happened: which papers were read, what experiments were run, what results were obtained, and what decisions were made. It is a timestamped log of events, similar to a lab notebook. The agent queries episodic memory to avoid repeating failed experiments, to recall what it found last week, and to maintain continuity across sessions.

Semantic memory stores what is known: domain facts, established methods, known constraints, and relationships between concepts. It is structured knowledge, often represented as a knowledge graph (see Chapter 38). The agent queries semantic memory to check whether a finding is novel, to look up standard protocols, and to ground its reasoning in established science.

Procedural memory stores how to do things: successful prompts, effective search strategies, reliable code patterns, and learned workflows. It captures expertise that the agent develops through experience. The agent queries procedural memory to reuse strategies that worked and to avoid strategies that failed.

Checkpoint

So far: research agents use three distinct memory stores, each serving a different purpose: episodic memory records what happened (events and decisions), semantic memory records what is known (domain facts and relationships), and procedural memory records how to do things (effective strategies and code patterns).

from dataclasses import dataclass, field
from datetime import datetime
import numpy as np


@dataclass
class MemoryRecord:
    """A single memory entry with metadata for retrieval."""
    content: str
    memory_type: str  # "episodic", "semantic", "procedural"
    timestamp: datetime = field(default_factory=datetime.now)
    importance: float = 0.5       # 0.0 to 1.0
    access_count: int = 0
    last_accessed: datetime | None = None
    embedding: np.ndarray | None = None
    metadata: dict = field(default_factory=dict)


class AgentMemory:
    """Three-store memory system for research agents.

    Retrieval uses a weighted combination of recency, importance,
    and semantic similarity (following Park et al., 2023).
    """

    def __init__(self, embedding_fn, decay_rate: float = 0.995):
        self.stores: dict[str, list[MemoryRecord]] = {
            "episodic": [],
            "semantic": [],
            "procedural": [],
        }
        self.embed = embedding_fn
        self.decay_rate = decay_rate

    async def add(
        self,
        content: str,
        memory_type: str,
        importance: float = 0.5,
        metadata: dict | None = None,
    ) -> MemoryRecord:
        """Store a new memory record."""
        embedding = await self.embed(content)
        record = MemoryRecord(
            content=content,
            memory_type=memory_type,
            importance=importance,
            embedding=embedding,
            metadata=metadata or {},
        )
        self.stores[memory_type].append(record)
        return record

    async def retrieve(
        self,
        query: str,
        memory_type: str | None = None,
        top_k: int = 5,
        recency_weight: float = 1.0,
        importance_weight: float = 1.0,
        relevance_weight: float = 1.0,
    ) -> list[MemoryRecord]:
        """Retrieve memories by weighted recency + importance + relevance.

        Score = alpha * recency + beta * importance + gamma * relevance
        where recency decays exponentially with time.
        """
        query_embedding = await self.embed(query)
        now = datetime.now()

        # gather candidates from specified store(s)
        candidates = []
        stores_to_search = (
            [memory_type] if memory_type else list(self.stores.keys())
        )
        for store_name in stores_to_search:
            candidates.extend(self.stores[store_name])

        if not candidates:
            return []

        scored = []
        for record in candidates:
            # recency: exponential decay by hours since creation
            hours_ago = (now - record.timestamp).total_seconds() / 3600
            recency = self.decay_rate ** hours_ago

            # relevance: cosine similarity (dot product of unit vectors)
            if record.embedding is not None and query_embedding is not None:
                cos_sim = np.dot(record.embedding, query_embedding) / (
                    np.linalg.norm(record.embedding)
                    * np.linalg.norm(query_embedding)
                    + 1e-8
                )
                relevance = float(cos_sim)
            else:
                relevance = 0.0

            score = (
                recency_weight * recency
                + importance_weight * record.importance
                + relevance_weight * relevance
            )
            scored.append((score, record))

        scored.sort(key=lambda x: x[0], reverse=True)

        # update access metadata
        results = []
        for _, record in scored[:top_k]:
            record.access_count += 1
            record.last_accessed = now
            results.append(record)

        return results

    def reflect(self, memories: list[MemoryRecord]) -> str:
        """Combine multiple memories into a higher-level summary.

        This implements the 'reflection' step from Park et al. (2023):
        periodically consolidate low-level observations into
        higher-level insights stored in semantic memory.
        """
        contents = "\n".join(
            f"[{m.memory_type}] {m.content}" for m in memories
        )
        return contents  # in production, pass to LLM for abstraction
A three-store agent memory system (episodic, semantic, procedural) with weighted retrieval combining exponential recency decay, agent-assigned importance, and cosine similarity, a measure of vector alignment ranging from -1 to 1.

The three memory types interact during a research session. When the literature agent finds a new paper, it creates an episodic record ("Read Smith et al. 2024 on Tuesday; method X outperforms method Y by 15%"). If the finding is significant, the agent also updates semantic memory, adding the performance comparison to the domain knowledge graph. A novel search strategy triggers a procedural record ("Searching for 'method X benchmark' on Semantic Scholar yields higher-quality results than PubMed for computational papers").

Key Insight: Memory Is What Separates an Agent From a Pipeline

Without memory, a research agent is just a sophisticated pipeline: it processes inputs, produces outputs, and forgets. With memory, it becomes something closer to a researcher: it builds cumulative understanding, avoids repeating mistakes, and develops expertise over time. The critical design choice is what to remember. Storing everything is expensive and creates retrieval noise. The importance scoring in AgentMemory.add() acts as a filter, analogous to a human researcher deciding which findings are worth recording in their lab notebook versus which are routine observations.

5. Optimal Stopping: When to Stop Searching

Memory lets an agent accumulate knowledge across sessions, but accumulation without a stopping rule means the agent keeps searching forever. A literature agent can always find one more paper. An analysis agent can always try one more statistical test. The fundamental question is: when should the agent stop gathering information and commit to an action? This is the optimal stopping problem, one of the most studied problems in decision theory.

The classic formulation is the secretary problem (also known as the best-choice problem). You interview \(n\) candidates sequentially. After each interview, you must immediately accept or reject the candidate; you cannot go back. The optimal strategy is to reject the first \(n/e \approx 0.368n\) candidates (the "exploration phase," where you gather information without committing), then accept the first candidate better than all those seen so far (the "exploitation phase," where you act on what you have learned). This gives a probability of selecting the best candidate that converges to \(1/e \approx 0.368\) as \(n \to \infty\).

$$ \text{Optimal threshold} = \left\lfloor \frac{n}{e} \right\rfloor \quad \text{where } e \approx 2.718 $$

Mental Model

Optimal stopping (37% rule) as apartment hunting: tour without commitment first, then grab the first one better than all you saw

Think of optimal stopping like apartment hunting in a new city. You have one month of viewings scheduled. For the first ~11 days (37% of your month), you tour apartments with no intention of signing a lease; you are calibrating your sense of what is available at your price point. After that calibration window, you commit to the first apartment that is better than everything you saw during the calibration phase. Without the calibration window, you would either grab the first decent place (missing better options) or keep looking forever (losing good options to other renters). A research agent's exploration phase works the same way: early search iterations calibrate the quality landscape, so the agent knows what "good enough" looks like before it commits to synthesizing or acting on a finding.

The analogy is imperfect, yet the core lesson transfers. An agent searching for papers, hypotheses, or analysis methods must eventually commit. Optimal stopping theory prescribes a specific exploration length: spend roughly 37% of the search budget exploring before exploiting the best finding so far.

In practice, research agents use a softer version: a confidence threshold combined with a diminishing returns detector. The agent tracks how much new information each search iteration provides. When the marginal information gain, the fraction of genuinely new information added by the latest iteration relative to the total accumulated so far, drops below a threshold, the agent stops:

class OptimalStoppingCriterion:
    """Decides when an agent should stop searching and start acting.

    Combines the 37% rule with a diminishing-returns detector:
    - Explore for at least 37% of the budget (exploration phase).
    - After that, stop when marginal gain falls below threshold.
    """

    def __init__(
        self,
        budget: int,
        gain_threshold: float = 0.05,
        exploration_fraction: float = 1.0 / 2.718,
    ):
        self.budget = budget
        self.gain_threshold = gain_threshold
        self.explore_until = int(budget * exploration_fraction)
        self.gains: list[float] = []

    def record_gain(self, new_information: float, total_information: float):
        """Record the marginal gain from the latest search iteration."""
        if total_information > 0:
            marginal = new_information / total_information
        else:
            marginal = 1.0
        self.gains.append(marginal)

    def should_stop(self, iteration: int) -> bool:
        """Apply the optimal stopping criterion."""
        # never stop during exploration phase
        if iteration < self.explore_until:
            return False

        # stop if budget exhausted
        if iteration >= self.budget:
            return True

        # stop if last 2 iterations had low marginal gain
        if len(self.gains) >= 2:
            recent_avg = sum(self.gains[-2:]) / 2
            if recent_avg < self.gain_threshold:
                return True

        return False


# usage in the literature agent loop:
# stopper = OptimalStoppingCriterion(budget=10)
# for i in range(10):
#     new_papers = search(...)
#     stopper.record_gain(len(new_papers), len(all_papers))
#     if stopper.should_stop(i):
#         break
An optimal stopping criterion combining a mandatory exploration phase (37% of budget) with a diminishing-returns detector that triggers when the average marginal gain over the last two iterations falls below 0.05.
Practical Example: Optimal Stopping in Hypothesis Search

A drug discovery team tasks a research agent with finding candidate molecules for a protein target. The agent has a budget of 20 search iterations across chemical databases. Using the 37% rule, it explores freely for the first 7 iterations, querying diverse chemical spaces without committing to any lead series. In iteration 8, it finds a scaffold with predicted binding affinity in the top 5% of everything seen so far. Because this exceeds the "best seen during exploration" threshold, the agent commits: it spends remaining iterations refining variations of this scaffold rather than continuing broad search. Without optimal stopping, the agent would have continued exploring through all 20 iterations, spending tokens on increasingly marginal chemical spaces. With it, the agent converges on a productive lead series in iteration 8 and uses the remaining budget for targeted optimization.

6. The Reviewer Agent: Closing the Loop

Optimal stopping tells an agent when to act, but it says nothing about whether the action was correct. The reviewer agent is the quality gate of the research pipeline. It receives the outputs of the other three agents (literature synthesis, code, analysis results) and checks for:

@dataclass
class ReviewResult:
    """Structured output from a reviewer agent."""
    verdict: str        # "accept", "revise", "reject"
    score: float        # 0.0 to 1.0
    issues: list[dict]  # [{"severity": "critical", "description": "..."}]
    suggestions: list[str]
    missing_references: list[str]
    reproducibility_score: float  # 0.0 to 1.0


class ReviewerAgent:
    """Agent that reviews research outputs for quality and correctness."""

    SYSTEM_PROMPT = """You are a scientific reviewer agent. You evaluate
    research outputs for correctness, rigor, and completeness.

    You are constructively skeptical: assume nothing is correct until verified.
    Check every statistical claim against the raw numbers. Verify that every
    citation supports the claim it is attached to. Flag missing controls,
    alternative explanations, and unstated assumptions.

    Output a structured review with:
    1. Verdict: accept, revise, or reject
    2. Score: 0.0 to 1.0
    3. Issues: list with severity (critical/major/minor) and description
    4. Suggestions: actionable improvements
    5. Missing references: papers that should have been cited
    6. Reproducibility score: 0.0 to 1.0
    """

    def __init__(self, llm_client, literature_agent):
        self.llm = llm_client
        self.lit_agent = literature_agent  # for citation verification

    async def review(
        self,
        synthesis: str,
        code: str | None = None,
        analysis: "AnalysisResult | None" = None,
    ) -> ReviewResult:
        """Review a complete research output."""
        # step 1: check citations
        citation_issues = await self._verify_citations(synthesis)

        # step 2: check methodology (if analysis provided)
        method_issues = []
        if analysis is not None:
            method_issues = await self._check_methodology(analysis)

        # step 3: check reproducibility (if code provided)
        repro_score = 1.0
        repro_issues = []
        if code is not None:
            repro_result = await self._check_reproducibility(code)
            repro_score = repro_result["score"]
            repro_issues = repro_result["issues"]

        # step 4: overall assessment
        all_issues = citation_issues + method_issues + repro_issues
        critical_count = sum(
            1 for i in all_issues if i["severity"] == "critical"
        )

        if critical_count > 0:
            verdict = "reject"
            score = 0.3
        elif len(all_issues) > 3:
            verdict = "revise"
            score = 0.6
        else:
            verdict = "accept"
            score = 0.9

        return ReviewResult(
            verdict=verdict,
            score=score,
            issues=all_issues,
            suggestions=[i["suggestion"] for i in all_issues if "suggestion" in i],
            missing_references=[],
            reproducibility_score=repro_score,
        )

    async def _verify_citations(self, text: str) -> list[dict]:
        """Spot-check citations against source papers."""
        response = await self.llm.chat(
            system=(
                "Extract all citations from this text. For each, assess "
                "whether the claim attributed to the citation is plausible "
                "based on the paper title and context. Flag suspicious ones."
            ),
            user=text,
        )
        # in production, cross-reference with the literature agent's
        # stored paper extractions
        return json.loads(response.content)

    async def _check_methodology(
        self, analysis: AnalysisResult
    ) -> list[dict]:
        """Check statistical methodology for common errors."""
        issues = []
        if not analysis.assumptions_met.get("normality", True):
            if "t_test" in analysis.test_name:
                issues.append({
                    "severity": "critical",
                    "description": (
                        f"Used {analysis.test_name} but normality "
                        "assumption is violated."
                    ),
                    "suggestion": "Switch to Mann-Whitney U test.",
                })
        if analysis.p_value < 0.05 and (analysis.effect_size or 0) < 0.2:
            issues.append({
                "severity": "major",
                "description": (
                    "Statistically significant but effect size is trivial "
                    f"(d={analysis.effect_size:.3f})."
                ),
                "suggestion": (
                    "Report as 'statistically significant but with "
                    "negligible practical effect.'"
                ),
            })
        return issues

    async def _check_reproducibility(self, code: str) -> dict:
        """Score code for reproducibility criteria."""
        checks = {
            "random_seed": "seed" in code.lower(),
            "version_pinning": "==" in code or "requirements" in code.lower(),
            "data_path_not_hardcoded": "/home/" not in code
                                       and "C:\\" not in code,
            "mlflow_logging": "mlflow" in code,
            "docstrings": '"""' in code or "'''" in code,
        }
        score = sum(checks.values()) / len(checks)
        issues = [
            {"severity": "minor", "description": f"Missing: {k}"}
            for k, v in checks.items() if not v
        ]
        return {"score": score, "issues": issues}
A reviewer agent that performs three sequential checks (citation verification, methodology auditing for violated assumptions and trivial effect sizes, reproducibility scoring) and produces a structured verdict with severity-ranked issues.

Research Frontier

The self-improving agent pattern introduced by Reflexion (Shinn et al., 2023), a framework where agents improve by reflecting on their own failures in natural language rather than updating model weights, has been pushed significantly further by recent work on autonomous scientific agents. In 2024, Ghafarollahi and Buehler introduced SciAgents, a multi-agent system where a literature agent, a computational agent, and a critic agent collaborate through structured debate to generate and validate novel research hypotheses in materials science. SciAgents uses an ontology-grounded knowledge graph as shared semantic memory, enabling agents to ground their reasoning in established domain relationships rather than relying solely on the LLM's parametric knowledge. On a benchmark of materials discovery tasks, the system produced hypotheses that domain experts rated as both novel and scientifically plausible, in some cases at rates approaching those of human researchers. This points toward a future where research agent teams do not merely automate known workflows but propose genuinely new scientific directions, with the knowledge graph acting as a shared "institutional memory" that prevents hallucination and enforces domain consistency.

Try It: Build a Minimal Literature Agent in 30 Minutes

You can build and test a stripped-down literature agent using only Python, the requests library, and a free API. Follow these steps:

1. Set up the search tool. Write a function that queries the Semantic Scholar API (https://api.semanticscholar.org/graph/v1/paper/search) with a query string and returns the top 10 results with title, year, abstract, and citation count. No API key is required for low-volume use.

2. Implement the agent loop. Create a loop that (a) searches with the initial query, (b) ranks results by citation count, (c) extracts the abstract of the top 3 papers, and (d) uses an LLM (or simple keyword extraction) to identify a gap in the results.

3. Add query refinement. Based on the identified gap, generate a follow-up query (this can be as simple as appending the most frequent novel term from abstracts to the original query). Run a second search iteration with the refined query.

4. Implement the stopping criterion. Track how many new unique papers each iteration adds. If the second iteration adds fewer than 3 new papers not seen in the first iteration, stop. Otherwise, allow up to 5 total iterations.

5. Produce the output. Print a structured summary listing each paper (title, year, citation count), the search queries used, and a one-paragraph synthesis of the main themes across all papers found. Compare the output quality of 1 iteration versus 3 iterations to see the value of adaptive search.

Exercises

  1. Conceptual: Draw a diagram showing the information flow between the four research agent roles (literature, coding, analysis, reviewer) for a hypothesis testing task. Label each edge with the data type being passed (e.g., "paper summaries", "code", "statistical results", "review feedback"). Identify which edges create feedback loops and explain why those loops are necessary. (See Figure 40.1 for the reference architecture.)
  2. Coding: Extend the AgentMemory class with a consolidate() method that groups related episodic memories (same topic, within 24 hours), summarizes them using an LLM, and stores the summary as a new semantic memory record. Test with a sequence of 20 episodic memories about reading papers on the same topic.
  3. Analysis: Compare the optimal stopping criterion with a simpler approach: stop after a fixed number of iterations (e.g., always search exactly 5 times). Run the literature agent on 10 different queries with each stopping strategy. Measure coverage (fraction of relevant papers found, as judged by a human expert) and cost (total API tokens consumed). When does optimal stopping outperform fixed stopping?

Exercise 40.1.1

The OptimalStoppingCriterion class uses a fixed exploration fraction of \(1/e \approx 0.368\). Suppose you have a budget of 12 search iterations and the marginal gains (new unique papers as a fraction of total papers found) for each iteration are: [1.0, 0.45, 0.30, 0.22, 0.18, 0.12, 0.09, 0.06, 0.04, 0.03, 0.02, 0.01]. At which iteration does should_stop() return True, and why? Walk through the logic step by step, checking both the exploration phase gate and the diminishing returns condition.

Hint

The exploration phase lasts until iteration floor(12 / 2.718) = floor(4.41) = 4, so iterations 0 through 3 always continue. Starting at iteration 4, check whether the average of the last two marginal gains falls below the default threshold of 0.05.

Step-Through: Weighted Memory Retrieval

Trace through AgentMemory.retrieve() with three stored memories and a query. Suppose the decay rate is 0.995 and the weights are all 1.0.

Memory A (episodic, stored 10 hours ago): importance = 0.9, cosine similarity to query = 0.6.
Recency = 0.995^10 = 0.951. Score = 1.0 * 0.951 + 1.0 * 0.9 + 1.0 * 0.6 = 2.451.

Memory B (semantic, stored 200 hours ago): importance = 0.7, cosine similarity = 0.95.
Recency = 0.995^200 = 0.367. Score = 1.0 * 0.367 + 1.0 * 0.7 + 1.0 * 0.95 = 2.017.

Memory C (procedural, stored 1 hour ago): importance = 0.3, cosine similarity = 0.4.
Recency = 0.995^1 = 0.995. Score = 1.0 * 0.995 + 1.0 * 0.3 + 1.0 * 0.4 = 1.695.

Ranking: A (2.451) > B (2.017) > C (1.695). Memory A wins because it combines high importance with high recency, even though Memory B has the strongest semantic match. This shows how the three-factor scoring balances "what matters" against "what is recent" and "what is relevant."

Real-World Application: Autonomous Chemistry with Coscientist

Carnegie Mellon's Coscientist system (Boiko et al., 2023) deploys exactly the four-role architecture described in this section for autonomous chemical synthesis. A literature agent searches papers for reaction protocols, a coding agent generates Python scripts to control a robotic liquid handler (Opentrons OT-2), an analysis agent interprets spectroscopy results, and a reviewer agent checks whether the synthesized product matches the target before approving the next experiment cycle. In benchmark tests, Coscientist autonomously planned and executed palladium-catalyzed cross-coupling reactions, reportedly achieving yields in the range of those produced by expert chemists. The forward and feedback data flows in this system mirror Figure 40.1.

The 37% Rule Finds You a Spouse (Mathematically)

The optimal stopping theory behind our literature agent's search strategy was first studied not for research papers but for marriage proposals. In the 1960s, mathematicians framed the "secretary problem" as advice for choosing a life partner: date roughly 37% of your potential partners (rejecting all of them), then commit to the next person who is better than everyone you dated during that window. Martin Gardner popularized this in his Scientific American column, and it became one of the most reprinted results in recreational mathematics. The same \(1/e\) threshold that tells our agent when to stop reading papers was once seriously proposed as a dating strategy.

Lab: Measuring the Value of Agent Memory

Goal: Empirically measure whether persistent memory improves a literature agent's performance across repeated search sessions on the same evolving topic.

Tools needed: Python 3.10+, the requests library, the free Semantic Scholar API (no key required for <100 requests/5 minutes), and numpy for cosine similarity (or any embedding library such as sentence-transformers).

Setup (15 min): Implement the AgentMemory class from this section with a simple embedding function (term frequency-inverse document frequency (TF-IDF) vectors or sentence-transformer embeddings). Write a minimal search loop that queries Semantic Scholar, stores each paper's title and abstract as an episodic memory, and uses retrieve() to check whether a "new" paper duplicates something already seen.

Experiment (15 min): Run three consecutive search sessions on the query "transformer protein structure prediction." In the memory condition, the agent retains its memory store between sessions. In the no-memory condition, the store is cleared before each session. What to vary: the number of sessions (2, 3, 5) and the decay rate (0.99, 0.995, 0.999). What to observe: (1) the number of duplicate papers retrieved across sessions, (2) the diversity of unique papers found (count unique paper IDs), and (3) the total API calls made.

What's Next

With the four research agent roles defined, equipped with memory and stopping criteria, Section 40.2: AI Scientists and Coscientist examines how real-world systems combine these roles into end-to-end scientific agents: Coscientist orchestrating chemistry tools, ChemCrow augmenting an LLM with domain expertise, and Google's AI Co-Scientist using debate and tournament ranking to generate and refine hypotheses. These agent roles are the building blocks; the next section shows what happens when they work together at scale.