Skip to main content

Chapter 19 · Capstone: Research Assistant

Part of Part V · Capstones

"You've learned every piece. Now you'll assemble them into something that feels like magic: a research assistant that reads, understands, and synthesizes."


This is the integration chapter. You've built agent loops, tools, memory, reasoning strategies, RAG pipelines, and multi-agent systems. Now you'll combine them into a single, complete application: a research assistant that takes any question, searches the web, reads relevant pages, analyzes findings, and produces a cited, comprehensive report.

This is not a toy. By the end of this chapter, you'll have a working research assistant you can actually use.


Section 1: Architecture

The research assistant uses a hierarchical multi-agent architecture:

┌──────────────┐
│ User │
│ Question │
└──────┬───────┘

┌──────▼───────┐
│ Orchestrator │ ← Plans, delegates, synthesizes
└──────┬───────┘

┌─────────────────┼─────────────────┐
│ │ │
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
│ Searcher │ │ Searcher │ │ Searcher │ ← Parallel web search
│ Agent │ │ Agent │ │ Agent │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
└─────────────────┼─────────────────┘

┌──────▼───────┐
│ Analyst │ ← Synthesizes findings
│ Agent │
└──────┬───────┘

┌──────▼───────┐
│ Writer │ ← Produces the report
│ Agent │
└──────┬───────┘

┌──────▼───────┐
│ Reviewer │ ← Fact-checks and improves
│ Agent │
└──────┬───────┘

┌──────▼───────┐
│ Final Report │
└──────────────┘

Why this architecture:

  • Orchestrator decomposes the question and coordinates. It doesn't do research itself — it manages.
  • Searchers work in parallel, each exploring a different angle. Three searchers find more than one.
  • Analyst synthesizes raw search results into structured findings. This is where understanding happens.
  • Writer turns analysis into a readable report. Separation of analysis and writing improves quality.
  • Reviewer catches hallucinations, weak arguments, and missing citations. The quality gate.

Section 2: The Orchestrator

The orchestrator is the brain of the system. It plans the research, delegates to specialists, and makes decisions.

class ResearchOrchestrator:
def __init__(self):
self.searcher = SearchAgent()
self.analyst = AnalystAgent()
self.writer = WriterAgent()
self.reviewer = ReviewerAgent()

async def research(self, question: str) -> dict:
# Phase 1: Plan
plan = await self._create_plan(question)

# Phase 2: Search (parallel)
search_tasks = [
self.searcher.search(angle)
for angle in plan["search_angles"]
]
all_results = await asyncio.gather(*search_tasks)
combined_results = self._deduplicate(all_results)

# Phase 3: Analyze
analysis = await self.analyst.analyze(
question=question,
search_results=combined_results,
)

# Phase 4: Write
draft = await self.writer.write_report(
question=question,
analysis=analysis,
sources=combined_results,
)

# Phase 5: Review
review = await self.reviewer.review(draft, combined_results)
if review["needs_revision"]:
draft = await self.writer.revise(draft, review["feedback"])

return {
"question": question,
"plan": plan,
"sources": combined_results,
"analysis": analysis,
"report": draft,
"review": review,
}

async def _create_plan(self, question: str) -> dict:
response = await llm.generate_structured(
system="""You are a research planner. Given a question, create a research plan.

Break the question into 3-5 search angles. Each angle should explore a different
aspect. Together, they should provide comprehensive coverage.

For each angle, provide:
- A specific search query
- What kind of information you're looking for
- Why this angle matters for answering the question""",
prompt=question,
schema={
"type": "object",
"properties": {
"decomposition": {"type": "string"},
"search_angles": {
"type": "array",
"items": {
"type": "object",
"properties": {
"angle": {"type": "string"},
"query": {"type": "string"},
"looking_for": {"type": "string"},
"importance": {"type": "string"},
},
},
},
},
},
)
return response

Section 3: The Searcher Agent

Each searcher explores one angle. It searches, reads the most promising pages, and extracts relevant information.

class SearchAgent:
async def search(self, angle: dict) -> dict:
results = []

# Step 1: Web search
search_results = await search_web(angle["query"], num_results=5)

# Step 2: Read and extract from each result
for sr in search_results:
try:
page_content = await fetch_and_extract(sr["url"])
relevance = await self._assess_relevance(
angle=angle,
url=sr["url"],
content=page_content[:5000], # First 5K chars
)
if relevance["score"] > 0.6:
results.append({
"url": sr["url"],
"title": sr["title"],
"content": page_content[:10000], # Keep first 10K chars
"relevance_score": relevance["score"],
"key_points": relevance["key_points"],
})
except Exception as e:
results.append({
"url": sr["url"],
"title": sr["title"],
"error": str(e),
})

return {
"angle": angle["angle"],
"query": angle["query"],
"results": results,
}

async def _assess_relevance(self, angle: dict, url: str, content: str) -> dict:
response = await llm.generate_structured(
system="""Assess whether this web page is relevant to the research angle.
Score from 0 (irrelevant) to 1 (highly relevant).
Extract 3-5 key points if relevant.""",
prompt=f"Angle: {angle['angle']}\nLooking for: {angle['looking_for']}\n\nURL: {url}\nContent: {content}",
schema={
"type": "object",
"properties": {
"score": {"type": "number"},
"reasoning": {"type": "string"},
"key_points": {"type": "array", "items": {"type": "string"}},
},
},
)
return response

Section 4: The Analyst Agent

The analyst takes raw search results and produces structured findings. This is where the magic happens — turning a pile of web pages into understanding.

class AnalystAgent:
async def analyze(self, question: str, search_results: list[dict]) -> dict:
# Flatten all results into a single context
all_findings = []
for angle_result in search_results:
for result in angle_result["results"]:
if "key_points" in result:
all_findings.extend(result["key_points"])

# Synthesize findings
analysis = await llm.generate_structured(
system="""You are a research analyst. Synthesize findings from multiple sources
into a coherent analysis.

Your analysis should:
1. Identify the main themes and patterns across sources
2. Note areas of consensus and disagreement
3. Highlight surprising or counterintuitive findings
4. Identify gaps — what's still unknown?
5. Assess the overall quality of evidence""",
prompt=f"""Question: {question}

Search findings from {len(search_results)} angles:

{json.dumps(all_findings, indent=2)}

Synthesize these findings into a structured analysis.""",
schema={
"type": "object",
"properties": {
"executive_summary": {"type": "string"},
"key_findings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"finding": {"type": "string"},
"confidence": {"type": "string"},
"supporting_sources": {"type": "array", "items": {"type": "string"}},
"counterpoints": {"type": "string"},
},
},
},
"themes": {"type": "array", "items": {"type": "string"}},
"disagreements": {"type": "array", "items": {"type": "string"}},
"gaps": {"type": "array", "items": {"type": "string"}},
"evidence_quality": {"type": "string"},
},
},
)
return analysis

Section 5: The Writer and Reviewer

Writer Agent:

class WriterAgent:
async def write_report(self, question: str, analysis: dict, sources: list[dict]) -> str:
# Collect all source URLs for citation
source_list = []
for angle_result in sources:
for result in angle_result["results"]:
if "url" in result and "title" in result:
source_list.append(f"- [{result['title']}]({result['url']})")

report = await llm.generate(
system="""You are a research writer. Write a clear, well-structured report
based on the provided analysis. Use the analysis as your source material —
do not fabricate additional facts.

Structure your report:
1. Executive Summary (2-3 sentences)
2. Background and Context
3. Key Findings (one section per major finding)
4. Analysis and Implications
5. Limitations and Open Questions
6. Sources

Cite specific sources inline using [Source: title] notation.
Write for an intelligent but non-expert audience.
Be precise. Avoid jargon. Use concrete examples.""",
prompt=f"""Question: {question}

Analysis:
{json.dumps(analysis, indent=2)}

Available sources:
{chr(10).join(source_list)}""",
)
return report

async def revise(self, draft: str, feedback: str) -> str:
return await llm.generate(
system="Revise this research report based on the reviewer's feedback.",
prompt=f"Original report:\n{draft}\n\nReviewer feedback:\n{feedback}\n\nRevised report:",
)

Reviewer Agent:

class ReviewerAgent:
async def review(self, report: str, sources: list[dict]) -> dict:
review = await llm.generate_structured(
system="""You are a research reviewer. Your job is to fact-check and quality-assess
research reports. Be strict. Every claim should be supported by sources.

Check for:
1. Factual accuracy — are claims supported by the provided sources?
2. Completeness — does the report address all aspects of the question?
3. Clarity — is the report well-structured and readable?
4. Citations — are sources properly cited?
5. Hallucinations — are there any claims NOT supported by sources?""",
prompt=f"Report:\n{report}\n\nSources:\n{json.dumps(sources, indent=2)}",
schema={
"type": "object",
"properties": {
"overall_score": {"type": "number"},
"needs_revision": {"type": "boolean"},
"feedback": {"type": "string"},
"hallucinations": {"type": "array", "items": {"type": "string"}},
"missing_coverage": {"type": "array", "items": {"type": "string"}},
},
},
)
return review

Section 6: Running the Research Assistant

async def main():
orchestrator = ResearchOrchestrator()

question = "What is the current state of nuclear fusion energy research, and when might it become commercially viable?"

print(f"Researching: {question}\n")
print("=" * 60)

result = await orchestrator.research(question)

print(f"\nPlan: {len(result['plan']['search_angles'])} search angles")
print(f"Sources: {sum(len(a['results']) for a in result['sources'])} pages analyzed")
print(f"Review score: {result['review']['overall_score']}/10")
print("\n" + "=" * 60)
print(result["report"])

# Run it
asyncio.run(main())

Sample output trace:

Researching: What is the current state of nuclear fusion energy research,
and when might it become commercially viable?

============================================================
[Orchestrator] Creating research plan...
[Orchestrator] 4 search angles identified
[Searcher 1] Searching: "nuclear fusion breakthrough 2024 2025"
[Searcher 2] Searching: "ITER project timeline commercial fusion"
[Searcher 3] Searching: "private fusion companies investment 2025"
[Searcher 4] Searching: "fusion energy technical challenges remaining"
[Searcher 1] Found 5 results, 3 relevant
[Searcher 3] Found 5 results, 4 relevant
[Searcher 2] Found 5 results, 2 relevant
[Searcher 4] Found 5 results, 3 relevant
[Analyst] Synthesizing 12 findings across 4 angles...
[Analyst] 5 key findings, 3 themes, 2 disagreements identified
[Writer] Drafting report...
[Reviewer] Reviewing report...
[Reviewer] Score: 7/10. Needs revision: 2 claims lack source support.
[Writer] Revising report...
[Reviewer] Final score: 9/10. Approved.

============================================================
[Final Report]
...

Section 7: Extending the Research Assistant

This is a foundation. Here's how to extend it:

  • Add RAG: Index your own documents alongside web search. The analyst synthesizes both.
  • Add memory: Remember past research. "Update my research on fusion from last month."
  • Add deep-dive mode: For critical findings, the searcher reads full papers, not just snippets.
  • Add expert critique: A domain-specific reviewer that checks for common misconceptions in the field.
  • Add visualization: The writer generates charts and diagrams, not just text.
  • Add subscriptions: "Monitor fusion energy news and send me a weekly briefing."

The Turn

You've built a complete research assistant. It plans, searches in parallel, reads and extracts, synthesizes findings, writes a report, and reviews its own work. This is not a demo — it's a working system that produces real value.

Every piece of this system is something you built in earlier chapters. The orchestrator is the agent loop from Chapter 4. The searchers use tool calling from Chapter 5. The analyst uses reasoning patterns from Chapter 7. The reviewer uses evaluation from Chapter 14. The whole thing is orchestrated with the workflow patterns from Chapter 15.

You didn't learn to build a research assistant. You learned to build agents — and a research assistant is just agents arranged in a specific pattern.


In the final chapter: The ultimate test. Build an agent that writes code, tests it, fixes bugs, and deploys — all while you watch. This is the capstone that proves you've mastered agentic AI.