Deep Research Agent
The pattern behind every "deep research" feature of 2026: given a question, the agent plans the sub-questions, searches multiple sources, verifies claims against evidence, and writes a structured, cited report. The most autonomous project here — a showcase of multi-step planning, tool use, and citation discipline.
What this project teaches you to design
- A plan → search → read → verify → synthesize loop (the research pattern).
- Citation discipline: every claim traceable to a source.
- Adversarial verification to fight hallucination in long outputs.
- Research-specific evals: coverage, citation accuracy, factual support.
The brief advanced
You have probably watched a "research agent" demo that dazzled you — until you clicked one of its citations and the linked page said nothing of the sort. That single moment of broken trust is the failure this entire project exists to prevent. Building an agent that reads fast is easy; building one you can trust to tell you only what a real source actually says is the hard, valuable part — and the difference lives in the design, not the model.
Picture the gap between a keen intern and a seasoned research analyst. The intern hands you a fluent, confident summary — and half of it is quietly invented, because "sound plausible" is exactly what a language model is best at. The analyst hands you the same summary with a footnote on every sentence, and when you check the footnotes, they hold up. This project builds the analyst, not the intern: a system whose output is only ever as strong as the evidence it can point to. The brief below states the job in business terms — market scans, due diligence, literature reviews — but read it with that intern-vs-analyst tension in mind, because every design choice on this page (planning, verification, citation integrity, budget caps) is really a mechanism for turning a fluent guesser into a disciplined citer. The common mistake teams make is treating this as a prompting problem ("just tell it to cite its sources") when it is an architecture problem — trust has to be enforced in code, not politely requested in a prompt, and that is what the rest of the page shows you how to do.
"Do the hours of reading-and-synthesizing that go into a briefing — with sources." Market scans, competitive analysis, literature reviews, due diligence — all involve gathering many sources and distilling them. An agent that plans the research, gathers and cross-checks sources, and produces a cited draft turns a day of work into minutes, with a human editing the final.
Compare the two panels side by side: the same summary, but the right side attaches evidence to every sentence and the left side does not.
- The left (intern) panel is a fluent summary followed by red boxes — a confident claim with no source. It looks authoritative, which is exactly why an invented fact here is dangerous.
- The right (analyst) panel carries the same claim, but green — each sentence ends in a citation like
[s1]that arrows out to a real page you can open and check. - The colour is the payoff: red = "trust me", green = "check me". This project is built to produce only the green kind of output.
In short: Same words, different trustworthiness — the analyst's every claim points to a source, and that traceability is the entire value of the build.
1 · Discovery — where does the time go? advanced
The seductive trap of any agent project is to automate the whole job at once — and it is exactly how you build something impressive that nobody uses. If you hand the agent the final judgment call, one hallucinated fact in a board briefing costs more than every hour it saved. So before writing a line of code, you do discovery: you look at how a human actually spends the day and ask, task by task, "does the agent have leverage here, and is the downside of a mistake survivable?"
Think of it like handing work to a talented but occasionally careless junior. You gladly delegate the grinding parts — reading forty tabs, pulling the same fact from five places, drafting the outline — because those are tedious, high-volume, and easy to check. You do not hand over the one decision that carries the reputation of the report: which sources to trust and what the final narrative should be. That is the logic behind the table's stars. The columns aren't rating difficulty; they're rating fit — how repetitive and verifiable the task is (agent wins) versus how much irreversible judgment it demands (human keeps it). Read each row as a deliberate line drawn between "draft this for me" and "I decide this," because that line is what makes the whole system safe to ship: the agent does the hours of gathering and cross-checking, and a person still owns the call at the end.
Start at the top box — a briefing's total work — then follow the two arrows to the kind of task, which decides who owns it.
- The left arrow leads to tasks that are repetitive and verifiable: searching, cross-checking, structuring. These are the green boxes — high agent leverage (⭐⭐⭐).
- The right arrow leads to the one task that carries irreversible judgment: deciding which sources to trust and the final narrative. That is the red box — low agent fit (⭐), the human keeps it.
- The colours aren't rating difficulty; they rate fit — how safely a mistake can be caught. Green tasks are checkable, so delegate; the red task's mistakes reach the reader, so keep it.
In short: Delegate the hours of gathering and cross-checking; keep the one call that owns the report's reputation — that split is what makes the system safe to ship.
| Where time goes | Agent leverage |
|---|---|
| Searching & skimming many sources | ⭐⭐⭐ high — parallel search & read |
| Cross-checking a claim across sources | ⭐⭐⭐ high — verification |
| Structuring findings into a report | ⭐⭐⭐ high — synthesis with citations |
| Judging source quality & final narrative | ⭐ low — human editor decides |
2 · Architecture advanced
The first instinct — "give the model a search tool and let it figure it out" — produces an agent that wanders. It searches, gets distracted by an interesting tangent, forgets what it was answering, and stops when it feels done rather than when the question is covered. The fix is not a smarter prompt; it is a shape — an explicit pipeline that forces the work through named stages in order, so no step can be skipped and every claim passes through the same gate before it reaches the page.
The analogy that makes this click is a newsroom, not a search box. A story doesn't go straight from tip to print: an editor breaks the assignment into angles, reporters chase each angle, a fact-checker challenges every claim against its source, and only then does anything get published — with an editor deciding whether there's enough to run. This architecture is that newsroom drawn as boxes: Plan (the editor splitting the question into sub-questions), the three gather tools (reporters — web search, fetch, and RAG over your own docs), Verify (the fact-checker, the amber box that is the whole point of the design), an enough? decision that either loops back for more or stops, and finally the synthesis into a cited report.
Why draw it as a fixed pipeline instead of a free-roaming agent? Because the shape is what gives you the guarantees. When gathering, verifying, and writing are separate stages, you can force the writer to see only verified findings — so an unsupported claim physically cannot reach the report. A free-form agent that does everything in one blur has no such choke point. The one loop (the dashed arrow back from "enough?") is the deliberate exception: research genuinely needs iteration, so the design allows exactly one controlled cycle, bounded by a budget, rather than open-ended wandering. Read the diagram once left-to-right, then trace the dashed arrow, and you have the entire project in your head.
This is the same pipeline as a component map: follow the blue arrows from the user's question through the pieces that actually run, and note which box owns which data.
- The user question enters the purple agent loop — the LLM that plans sub-questions and decides which tool to call next.
- The three blue tool boxes are read-only:
web_searchandfetch_urlreach the public web, whilesearch_internaldoes RAG over a vector store of your own docs. Everything they find is logged into the green gathered set asclaim + source_id. - The amber verify box reads that gathered set and re-checks each claim against its source, so only supported findings flow into the cited report — which a human edits. The dashed grey arrow is the one budget-capped loop back to the tools when coverage is thin.
In short: User → LLM agent loop → read-only tools (+ vector store) → gathered set → verify → cited report → human — the gathered set and the verify gate are the two components that make citations trustworthy.
This is the whole agent as a pipeline, read left to right. A research question goes in one end; a cited report comes out the other. Every project on this page is just code that walks these boxes in order.
- Question → Plan — the question first hits the purple Plan box, which breaks it into sub-questions (smaller, focused angles to research). This is Step 2 in the build.
- The three middle boxes —
web search,fetch / read, andinternal docs (RAG)are the tools the agent uses to gather evidence for each sub-question. They all feed the next box. - Verify (amber box) — every gathered claim is re-checked against the source it came from (claim ↔ source). Unsupported claims are dropped here. This is the anti-hallucination heart of the design.
- enough? → loop or stop — the agent asks whether it has covered the question. The dashed blue arrow curving back to the tools is the loop: if there are gaps, it searches again (bounded by a budget).
- Cited report — the final purple box writes the report from verified findings only, with a citation on every claim.
In short: Follow the solid arrows once (Question → Plan → tools → Verify → enough? → Cited report), then the dashed arrow shows the one loop back. Plan, gather, verify, synthesize — that's the entire project.
A genuinely agentic loop (Ch 4): the agent plans sub-questions, gathers evidence from web search/fetch and internal docs (RAG), verifies each claim against its source, decides whether it has enough (looping back to search gaps), and finally synthesizes a cited report. This is the most "Tier 4" of the projects.
3 · Risk & quality model advanced
Every other project on this site has a "safety model" about not deleting things. A DevOps agent could drop a table; a support agent could refund the wrong customer. So your instinct arriving here is to ask "what can this agent break?" — and the answer is nothing. It only reads. That absence is a trap, because it makes a research agent feel low-risk when it is actually one of the more dangerous patterns you can ship.
The danger is a different kind of harm, and naming it is the point of this section. A research agent's damage is epistemic: it puts false beliefs into a human's head. The classic image is a forged document — it does no physical harm sitting on a desk, but the moment someone acts on it, the harm is real and often irreversible. A confident, fluently-written report with three fabricated statistics is exactly that forged document: the more polished it reads, the more it gets trusted, and the trust is what does the damage. This is why, for a research agent, the quality controls are the safety model — there is no separate "don't break prod" layer, because the way this agent breaks things is by being wrong persuasively.
Read the table below as a threat model, not a QA checklist. Each red row is a way the agent can hand you a plausible lie — invented facts, faked citations — and each control is a mechanism that makes that specific failure hard to commit, not just discouraged. The mistake to avoid is trusting any control that lives only in the prompt ("please don't make things up"); the durable controls in this table are the ones enforced in code and by a mandatory verify pass, which is why the golden-rule callout that closes the section is stated as an absolute.
Research agents don't touch infrastructure — the risks are epistemic: fabricated facts, fake citations, and shallow coverage. Quality controls are the safety model.
Read the two panels as "the risk you expected" versus "the risk that's actually there". The left is reassuring; the right is the chain you must break.
- The left panel shows the usual worry — agents that can write, delete or send — and notes that this agent is read-only (green), so it "breaks nothing directly". That is the trap: it feels low-risk.
- The right panel is the real danger as a chain of red arrows: a polished report with false facts → a false belief in a human's head → someone acts on it → real, often irreversible harm.
- The bottom line is the twist: the more polished it reads, the more it is trusted — so fluency amplifies the harm instead of reducing it. That is why controls can't just be politely requested in a prompt.
In short: A research agent's harm is epistemic, not physical — it plants false beliefs — so for this agent the quality controls are the safety model.
| Risk | Control |
|---|---|
| 🔴 Hallucinated facts in a long report | Every claim must cite a retrieved source; a verify pass re-checks each claim against its evidence and drops unsupported ones |
| 🔴 Fabricated or misattributed citations | Citations come only from sources actually fetched (tracked by the tool), never invented; the writer can only cite from the gathered set |
| 🟠 Shallow / one-sided coverage | Plan diverse sub-questions; a "completeness critic" checks what's missing and triggers more search |
| 🟠 Low-quality / biased sources | Prefer reputable domains; note source type; a human editor makes the final call |
| 🟠 Prompt injection from a fetched page | Web content is untrusted — instructions embedded in a page must not hijack the agent (Ch 6) |
| 💸 Runaway cost (endless searching) | Cap search rounds / token budget; stop when marginal new info is low |
4 · Tool surface advanced
An agent is only as capable — and only as safe — as the tools you give it. The tool surface is the complete list of actions the model is allowed to take in the world; everything else is off-limits by construction. So designing this list is not a chore before the real work, it is the security boundary, and the striking thing about a research agent's list is what's missing from it: there is no write, no delete, no send. Every tool is read-only or a scratchpad. That is deliberate — the fastest way to keep an agent from doing damage is to never hand it a tool that can.
The one tool worth pausing on is note_finding, because it does the quiet heavy lifting of the whole design. It looks like a trivial "write this down" scratchpad, but it is actually the mechanism that makes citations honest. When the agent gathers a fact, it must record the claim together with the id of the source it came from — and later, the writer is allowed to cite only from that recorded set. Think of it as an evidence locker with a chain of custody: nothing can be cited in the final report unless it was logged here first, by a tool, at the moment it was actually retrieved. The model never gets to conjure a citation, because the set of citable sources is built by your code, not by the model's imagination. Read the risk column as the design's conscience: green everywhere, and the two truly powerful capabilities (web search, fetch) come with the standing warning that fetched web content is untrusted — a page can contain instructions trying to hijack your agent, which the next chapters teach you to defend against.
[s99]) is never in the locker and gets dropped.
Follow the arrows left-to-right: the tools on the left feed a locker, and the writer at the bottom may draw only from that locker.
- The three blue boxes on the left are the read-only gather tools —
web_search,fetch_url,search_internal. None can write or delete; they only find evidence. - They all flow into
note_finding(green), which records each claim together with its source id into the gathered set — the only ids anyone is later allowed to cite. - The writer box may cite only from that set. So when the red box shows the model inventing a citation
[s99], the red arrow hits a wall —s99isn't in the locker, so it's dropped in code.
In short: Citable sources are built by your tools, not the model's imagination — nothing reaches the report unless it was logged in the evidence locker first.
| Tool | Does | Risk |
|---|---|---|
web_search | Find candidate sources for a sub-question | 🟢 read-only |
fetch_url | Retrieve & extract a page's content | 🟢 read-only (untrusted content) |
search_internal | RAG over your own docs/reports (Ch 3) | 🟢 read-only |
note_finding | Record a claim + its source (builds the citation set) | 🟢 scratchpad |
verify_claim | Re-check a claim against its cited source (can be a sub-agent) | 🟢 read-only |
search_internal (Ch 3 RAG) so the agent can mix public and proprietary sources. The note_finding scratchpad is how the writer later cites only real, gathered evidence.5 · The research loop — plan, gather, verify, synthesize advanced
Ask a beginner to build a research agent and you get one giant prompt: "research this question and write a cited report." It works in the demo and falls apart in production — the model gathers and writes in one breath, so it has no moment where it could stop and check whether a claim it just wrote is actually true. The loop below exists precisely to break that one big step into separate, inspectable stages, and the separation is the whole trick.
The reason a good analyst never merges these steps is worth internalizing: you cannot fact-check yourself in the same motion as writing. The mindset that generates fluent prose ("make it sound complete") is the opposite of the mindset that catches a bad claim ("prove this sentence"). So the loop physically separates them — plan, then gather, then verify as its own adversarial pass, then assess coverage, then and only then synthesize. Each stage hands a clean artifact to the next: the plan is a list of sub-questions, gathering produces findings-with-sources, verification produces the subset that survived scrutiny, and synthesis sees only that survivor set. Because verification is its own step with its own strict prompt, a claim the evidence doesn't support gets dropped before the writer ever sees it — which is impossible in the one-giant-prompt version.
The step to respect most is "assess coverage," because it's where cost and quality collide. Real research needs iteration — the first pass always leaves gaps — but iteration is also where an agent burns money and time endlessly. So this stage is a bounded loop: a "what's missing?" critic can trigger one more round of searching, but only within a budget. That single guarded cycle is the difference between an agent that digs deeper when it should and one that spirals forever chasing marginal facts.
This is the richest agent pattern in the course. It composes several techniques:
Read the top row left-to-right as the four stages, then follow the boxes downward — each one is narrower than the one above it, because the set of claims is being filtered.
- The top row is the pipeline: Plan (blue) makes sub-questions, Gather collects findings each tagged with its source id, and the amber Verify box strictly asks "does the source actually support this claim?"
- The three stacked boxes below show the shrinking: the widest box is all noted findings (some unsupported); the amber "drop unsupported" arrow feeds the narrower green box — the verified subset; that in turn feeds the smallest purple box, the cited report.
- The dashed grey arrow curving back up is the one bounded loop: if a coverage critic finds gaps, the agent runs one more search round — capped by a budget so it can't spiral forever.
In short: the claim set only ever gets smaller as it descends — gather wide, verify hard, synthesize from the survivors only.
- Plan — decompose the question into sub-questions (structured output: a list of angles to cover).
- Gather — for each sub-question, search + fetch (often in parallel), and
note_findingwith the source. - Verify — for each noted claim, re-check it against its source (an adversarial "is this actually supported?" pass — the Ch 5 idea). Drop unsupported claims.
- Assess coverage — a "what's missing?" critic; if there are gaps, loop back and search more (bounded by a budget).
- Synthesize — write the report from verified findings only, citing each claim.
6 · Evaluation expert
Here is the trap that catches every first-time builder of a research agent: the output reads beautifully, so you assume it's good. Fluency is precisely what a language model optimizes for, which means "sounds authoritative" tells you nothing about "is true." Eyeballing a polished report and nodding is the single most common way a broken research agent ships — it feels like evaluation, but you're only measuring prose, not correctness.
The mental shift is to stop grading the writing and start auditing the evidence. An editor doesn't judge a piece by how confident it sounds; they pull three citations at random and check that the sources actually say what the sentence claims. That is exactly what these evals mechanize. The keystone metric is citation accuracy — for a sample of claims, does the cited source genuinely support them? — because it directly measures the failure mode that matters. Alongside it sit groundedness (what fraction of claims are backed at all), coverage (did it answer the sub-questions a good analyst would), and cost.
Notice the crucial distinction the table draws between two kinds of check, because it's a pattern you'll reuse everywhere. Some evals are deterministic — "every citation was actually fetched by a tool" is a plain set-membership test in code, and it either passes or fails with no judgment involved. Others are judged — "does this source support this claim" needs an LLM-judge plus spot human checks, because it's a matter of degree. The common mistake is reaching for a fuzzy LLM-judge on a question that could have been a hard-coded assertion: whenever a rule can be checked deterministically (like citation integrity), do it in code, where it's free, instant, and never wrong — and save the expensive judged evals for the genuinely subjective calls.
Start at the top box — a claim with its citation — then follow the two blue arrows down to the kind of check each question needs. The left branch is cheap and certain; the right branch is expensive and subjective.
- The left green box is the deterministic check: "was this source actually fetched by a tool?" is a plain set-membership test in code — it either passes or fails with no judgment involved.
- The right amber box is the judged check: "does this source support this claim?" is a matter of degree, so it needs an LLM-judge backed by human spot-checks.
- The two light-blue boxes at the bottom are the trade-off: the code check is free, instant, and never wrong; the judged check costs tokens and must be sampled by hand.
In short: if a rule can be checked in code, do it there — reserve the LLM-judge for the genuinely subjective calls.
| Eval | Measures |
|---|---|
| Citation accuracy | Does each cited source actually support the claim it's attached to? (sample & check — the key metric) |
| Factual support / groundedness | What fraction of report claims are backed by a real source? (LLM-judge + spot human check) |
| Coverage | Does the report address the key sub-questions a good analyst would? (rubric) |
| No-fabricated-citation (deterministic) | Every citation URL/source was actually fetched by a tool (cross-check the gathered set) |
| Cost / rounds | Stayed within the search/token budget |
7 · Phased rollout expert
The exciting version of this project — plan, parallel search, verify, coverage critic, full cited reports — is also the version most likely to die half-built. You spend three weeks wiring every stage, nothing works end-to-end until the last day, and you can't tell which of five moving parts is producing garbage. That is the failure a phased rollout is designed to avoid: instead of building the cathedral in one go, you ship the smallest thing that proves the hardest idea, then add capability one rung at a time.
The ordering here is not arbitrary, and seeing the logic is the lesson. Phase 1 does the least ambitious task — answer one focused question with citations — but it proves the scariest property first: that the agent never cites a source it didn't fetch. Nail citation discipline on an easy case and you've de-risked the whole project; get it wrong and nothing built on top can be trusted anyway. Only then does Phase 2 add multi-step planning and the verify pass, and Phase 3 the coverage critic, internal RAG, and budget controls. Each rung is independently shippable and independently testable, so a regression has one obvious suspect: the thing you just added.
The rung that never changes is the last one, and it is the most important design decision on the page: a human edits before anything is published or acted on. This isn't a temporary training-wheels measure you remove once the agent is "good enough" — it's the permanent boundary that keeps the epistemic risk from §3 survivable. The agent drafts; a person decides. Ship the autonomy in phases, but never ship away that final human check.
Read the phase boxes as a rising staircase left-to-right, then notice the amber band that sits above all of them.
- Phase 1 is the lowest, least ambitious rung — answer one focused question with citations — but it proves the scariest property first: the agent never cites a source it didn't fetch.
- Phase 2 and Phase 3 each add one capability (planning + verify, then coverage critic + internal RAG + budgets). Each rung is independently shippable, so a regression has one obvious suspect: the thing you just added.
- The amber band across the top is the "Always" rung — a human edits before anything is published. It isn't a phase you outgrow; it spans every phase and is the permanent boundary that keeps the epistemic risk survivable.
In short: Ship autonomy one rung at a time, hardest-property-first — but never ship away the human edit at the end.
Skills & course map expert
| Skill | Learn it in |
|---|---|
| Multi-step agentic loop (plan/gather/synthesize) | Ch 4 |
| Adversarial verification of claims | Ch 5 |
| Citations, internal-source RAG | Ch 3 |
| Structured plan / findings schema | Python P4 |
| Budget caps, untrusted web content, deploy | Ch 6 |
| Streaming a long report to the user | Ch 1 + P4 async |
Build-along plan expert
- Single-pass first (Ch 4): a web-search + fetch loop that answers a focused question with citations. Prove it never cites a source it didn't fetch.
- Add planning (P4 schema): decompose the question into 3–5 sub-questions before searching.
- Add verification (Ch 5): for each claim, a check that the cited source supports it; drop the ones that fail.
- Add a coverage critic: "what's missing?" → one more search round, bounded by a budget.
- Synthesize: write a structured, cited report from verified findings only; stream it out.
- Evals (Ch 5): sample claims and check citation accuracy + coverage on a set of test questions.
Learning objectives
- Build the plan → gather → verify → synthesize loop.
- Enforce citation integrity in code (no invented sources).
- Verify each claim against its source before it reaches the report.
- Measure citation integrity and coverage with evals.
What you'll build expert
Ask a research question; the agent plans sub-questions, gathers from a source corpus, verifies each claim against its source, and writes a cited report using only verified findings. A human edits the final — the agent drafts.
llm-course-starter/research-agent/. Design rationale in the design chapter.Step 1 · A mock source corpus expert
mock/sources.py is a fake 'web' with search() and fetch() — so the whole loop runs offline and deterministically. It includes an irrelevant "cast iron" source, so evals can check the agent doesn't cite off-topic material. Swap in real web_search/web_fetch later — same interface.
Step 2 · Plan the sub-questions expert
Uses the client / objects set up earlier in this lesson, and needs an API key (ANTHROPIC_API_KEY for Claude, or OPENAI_API_KEY for OpenAI). Run the earlier blocks first.
Plan the sub-questions (structured call) — in Claude or OpenAI
The Pydantic models, prompts, and plumbing are vendor-neutral — only the structured-output call changes. Claude uses client.messages.parse(output_format=…) → resp.parsed_output; OpenAI uses client.responses.parse(text_format=…) → resp.output_parsed. Toggle the tabs.
agent/engine.pydef plan(question):
p = client.messages.parse(model=MODEL, max_tokens=400,
system="Break the research question into 3-5 focused sub-questions.",
messages=[{"role":"user","content":question}],
output_format=Plan)
return p.parsed_output.sub_questionsagent/engine.pydef plan(question):
p = client.responses.parse(model=MODEL, max_output_tokens=400,
instructions="Break the research question into 3-5 focused sub-questions.",
input=question,
text_format=Plan)
return p.output_parsed.sub_questionsThe first real step of the agent: turn one big, vague question into a short list of focused sub-questions. Instead of asking the model to "research X" in one shot, we make it plan first — the same way a good analyst sketches an outline before reading anything.
plan(question)is a normal function that takes the user's research question as its one input.- The structured
.parse(...)call (Claude'sclient.messages.parsewithoutput_format=Plan, or OpenAI'sclient.responses.parsewithtext_format=Plan) is a model call that returns structured data, not free text. It says "give the answer back shaped like ourPlanobject" (a Pydantic model defined earlier) — so we get a clean list, not a paragraph to parse by hand. - The
systemline is the instruction: break the question into 3-5 focused sub-questions. Themessagesline passes the actual question in as the user turn. - Returning
p.parsed_output.sub_questions(Claude) orp.output_parsed.sub_questions(OpenAI) hands back just the list of sub-questions — the plan the rest of the loop will work through.
What the output means: You get a Python list of 3-5 short strings, e.g. ["How big is the EV market?", "What are battery cost trends?", ...] — one search target per angle.
Try this: Change the system instruction to "2-3 sub-questions" and the plan gets shorter. This one prompt controls how broad the whole research pass will be.
Step 3 · Gather — with citation integrity expert
agent/engine.py# track the source_ids we actually gathered
gathered = {}
for sq in sub_questions:
for hit in sources.search(sq, k=2):
gathered[hit["id"]] = {"text": sources.fetch(hit["id"]), ...}
# model extracts 'claim | source_id' lines from ONLY these sources
for line in extracted.splitlines():
claim, _, sid = line.rpartition("|")
if sid.strip() in gathered: # REJECT invented citations
findings.append(Finding(claim=claim.strip(), source_id=sid.strip()))
This is the step that makes the agent trustworthy: it gathers evidence and, crucially, remembers exactly which sources it actually pulled — so later nothing can cite a source that was never fetched. Read it as two halves: gather, then extract claims safely.
gathered = {}is a dictionary that will hold every source the agent really retrieved, keyed by itsid. This set is the agent's "allowed to cite" list.- The nested
forloops walk each sub-question, runsources.search(sq, k=2)to get the top 2 hits, and store each hit's fetched text ingatheredunder itsid. Now every source the agent has seen is recorded. - The model then returns lines shaped
claim | source_id.line.rpartition("|")splits each line at the last|into the claim text and the source id it points at. if sid.strip() in gathered:is the guard — a claim is kept only if itssource_idis one we truly gathered. If the model invented a citation,sidwon't be ingatheredand the line is thrown away. Kept lines becomeFindingobjects.
What the output means: findings ends up as a list of Finding(claim, source_id) objects where every source_id is real — invented citations were silently dropped by the if check.
Try this: Imagine the model returns a line ending in | s99 but no source s99 was ever fetched. Trace it: "s99" in gathered is False, so the line is skipped. That single line is the whole anti-hallucination rule.
source_id that's in gathered — the set of sources actually returned by search/fetch. If the model invents a citation, it's dropped in code. The model can't fabricate a source, because the citation set is controlled by your tools, not the model.Step 4 · Verify each claim expert
Uses the client / objects set up earlier in this lesson, and needs an API key (ANTHROPIC_API_KEY for Claude, or OPENAI_API_KEY for OpenAI). Run the earlier blocks first.
Verify each claim (structured call) — in Claude or OpenAI
The Pydantic models, prompts, and plumbing are vendor-neutral — only the structured-output call changes. Claude uses client.messages.parse(output_format=…) → resp.parsed_output; OpenAI uses client.responses.parse(text_format=…) → resp.output_parsed. Toggle the tabs.
agent/engine.pydef verify(finding, gathered):
src = gathered.get(finding.source_id)
v = client.messages.parse(model=MODEL, max_tokens=300,
system="Does the source text actually support the claim? Be strict.",
messages=[{"role":"user","content":
f"Source: {src['text']}\n\nClaim: {finding.claim}\n\nSupported?"}],
output_format=Verdict)
return v.parsed_output.supported
verified = [f for f in findings if verify(f, gathered)] # drop unsupportedagent/engine.pydef verify(finding, gathered):
src = gathered.get(finding.source_id)
v = client.responses.parse(model=MODEL, max_output_tokens=300,
instructions="Does the source text actually support the claim? Be strict.",
input=
f"Source: {src['text']}\n\nClaim: {finding.claim}\n\nSupported?",
text_format=Verdict)
return v.output_parsed.supported
verified = [f for f in findings if verify(f, gathered)] # drop unsupportedGathering a claim with a real source id isn't enough — the source might not actually say what the claim says. This step re-reads each source and asks the model, strictly: does this evidence support this claim? Claims that fail are dropped before they ever reach the report.
verify(finding, gathered)takes one finding and the gathered sources.src = gathered.get(finding.source_id)looks up the exact source text the claim points to.- It makes a model call whose
systeminstruction is deliberately harsh — "Be strict." — so borderline or unsupported claims get rejected rather than waved through. - The user message pastes the source text and the claim together and asks "Supported?". Asking for the
Verdictschema (output_format=Verdicton Claude,text_format=Verdicton OpenAI) forces a clean yes/no answer (asupportedboolean), not a wishy-washy paragraph. verified = [f for f in findings if verify(f, gathered)]is a list comprehension that keeps only the findings whose verify call returned true — a one-line filter that drops every unsupported claim.
What the output means: verified is the subset of findings the source text genuinely backs up. Anything the model couldn't confirm against its own cited source is gone.
Try this: Make the system prompt lenient ("if it's roughly related, say supported") and more weak claims survive; make it stricter and fewer do. This dial is the same idea used as an eval in Ch 5.
Step 5 · Synthesize from verified findings only expert
terminalpython agent/engine.py # needs key
The global EV market grew strongly in 2025: sales reached 17 million
units, up 25% year over year, with China about 60% of the total [s1].
Battery pack costs fell to $110/kWh [s2], and public charging points
passed 5 million globally [s3]...
Sources:
[s1] https://ex.com/ev-2025
[s2] https://ex.com/batteries
[s3] https://ex.com/charging
The report is written from only the verified findings — every sentence traceable to a real, gathered, verified source. The cast-iron source never appears.
This is the payoff: the finished report the agent writes from the verified findings only. You ran python agent/engine.py and this text is what it printed. Notice every factual sentence carries a bracketed source tag.
- Each claim ends with a citation like
[s1],[s2],[s3]— a pointer to the exact source that backs it up. No sentence is left unsourced. - The Sources block at the bottom lists what each tag maps to (a real URL), so a human editor can click through and check any claim in seconds.
- Because the writer is only allowed to use verified findings, the off-topic "cast iron" source from the mock corpus never appears — the agent didn't pad the report with irrelevant material.
What the output means: A short, cited briefing where every claim is traceable to a gathered, verified source — exactly the "no claim without a citation" golden rule made real.
Try this: Compare this to the risk table earlier on the page: "hallucinated facts" and "fabricated citations" are the top risks, and this output is the proof the code controls them.
6 · Tests & evals expert
Two layers of check guard the agent: fast, offline pytest that needs no API key (deterministic rules like the citation-integrity guard), and live evals.py that call the real model to catch fabricated or off-topic citations. The diagram shows both seams and the pass/fail gate.
pytest seam (green) runs with a mock corpus and no key to prove the citation-integrity rule; the live evals seam (amber) calls the real model for fabricated/off-topic citations — and either one failing turns the gate red.
Read the two columns as the two seams where the agent is tested, then follow their arrows down to the single gate that decides ship or fix.
- The left (green) column is the deterministic
pytestseam: it runs offline against a mock corpus with no API key, and hard-checks rules like citation integrity — only gathered ids can be cited. It either passes or fails, no judgment. - The right (amber) column is the live
evals.pyseam: it calls the real model and checks for fabricated citations, off-topic sources, and coverage — the checks that need a running model. - Both columns feed the pass/fail gate at the bottom: the green ✅ ships, the red 🔴 sends you back to fix. Any single hard-fail check turns the whole gate red.
In short: Deterministic rules go in pytest (free, offline, certain); model-dependent quality goes in evals (needs a key) — and both must be green before you ship.
terminalpython -m pytest tests/ -v # no key
python evals.py # needs key
# tests
test_search_finds_relevant_sources PASSED
test_fetch_returns_text PASSED
test_citation_integrity_rule PASSED
4 passed
# evals
invalid citations: [] (expect [])
cited irrelevant source: False (expect False)
EV sources cited: 3/3
✅ evals passed
The final step proves the agent behaves — twice. pytest runs fast, offline tests (no API key), and evals.py runs the live checks that need a key. This is how you turn "it seemed to work" into evidence.
- The # tests section is
pytestoutput. Each line endingPASSEDis one automated check that ran green — e.g.test_citation_integrity_ruleconfirms only gathered ids can be cited.4 passedis the summary. - The # evals section is the live-model quality report.
invalid citations: []means zero fabricated sources slipped through (the list is empty, which is whatexpect []wants). cited irrelevant source: Falsemeans the agent didn't cite the off-topic cast-iron source.EV sources cited: 3/3means it grounded the report in all the relevant sources — good coverage.✅ evals passedat the end is the go/no-go signal: every hard-fail check (no invalid citations, no irrelevant ones) held.
What the output means: Green across the board: the citation-integrity rule holds offline, and with a real model there are no fabricated or off-topic citations and full coverage of the relevant sources.
Try this: Break the if sid in gathered guard from Step 3 on purpose, re-run, and watch invalid citations stop being empty — the eval is designed to catch exactly that regression.
| Check | Needs key? | Proves |
|---|---|---|
| search finds relevant, excludes cast-iron | No | retrieval is on-topic |
| citation-integrity rule | No | only gathered ids can be cited |
| no invalid citations (eval) | Yes | no fabricated sources — hard-fail |
| no irrelevant source cited (eval) | Yes | doesn't pad with off-topic — hard-fail |
| coverage: cites the EV sources | Yes | the report is actually grounded |
Troubleshooting expert
| Symptom | Fix |
|---|---|
| Report has uncited claims | Strengthen the synthesize prompt ("only these findings, cite each"); ensure you pass verified findings only |
| Fabricated citation slips through | Confirm the if sid in gathered filter runs; the eval hard-fails on invalid ids |
| Verify drops good claims | Verifier too strict, or the source text is truncated — pass full source text |
| Endless searching (real web) | Cap search rounds / token budget (Ch 6); stop when little new info appears |
| Injected instructions from a page | Treat fetched content as untrusted data (Ch 6); it must not change the agent's task |
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Offline research needs a corpus with stable ids, because citations only mean something if the ids are real and fetchable. Keeping search dumb (keyword overlap) lets the whole thing run with no model.
Your task: Build an in-memory corpus keyed by id with search(query, k) (keyword overlap) and fetch(source_id) — everything downstream cites these ids.
Requirements:
- Sources are a dict keyed by stable ids
searchranks by token overlap and returns idsfetchreturns a source's text by id- No model or network is used
- Ids are the anchor every later citation refers to
💡 Hint: Rank by set-overlap of tokens so it needs no embeddings; the ids you return here are what every citation check validates against later.
Show solution
Design. Offline research needs a corpus with stable ids — citations are only meaningful if " "ids are real and fetchable. Keep search dumb (overlap) so it runs with no model.
import re
def toks(s): return set(re.findall(r"[a-z0-9]+", s.lower()))
SOURCES = {
"s1": "Churn rose to 8 percent in Q3 driven by onboarding friction.",
"s2": "Net revenue retention held at 105 percent.",
"s3": "Support wait times doubled after the March release.",
}
def search(query, k=2):
q = toks(query)
scored = sorted(SOURCES, key=lambda i: len(q & toks(SOURCES[i])),
reverse=True)
return [{"id": i} for i in scored[:k]]
def fetch(sid): return SOURCES[sid]
print(search("why did churn rise")) # [{'id': 's1'}, ...]
print(fetch("s1")[:20])
Context: The heart of the agent is gathering findings while rejecting invented citations. The model may cite only from ids you actually handed it — any other id is dropped before it enters the pipeline.
Your task: Write gather that extracts claim | source_id lines and keeps a finding only if its id is one you actually gathered.
Requirements:
- Search and fetch populate a gathered set of ids
- Model output is parsed into claim / source-id pairs
- A finding whose id isn't in the gathered set is dropped
- An invented id (not returned by search) is rejected
- This is what stops hallucinated sources entering the pipeline
💡 Hint: Split each line on the last |, then keep the finding only if its id is in the gathered dict — reject anything the model made up.
Show solution
Design. The model is allowed to cite only from the ids you handed it. Any line whose id isn't "
"in gathered is dropped — this is what stops hallucinated URLs from entering the pipeline.
from dataclasses import dataclass
@dataclass
class Finding:
claim: str
source_id: str
SOURCES = {"s1": "Churn rose to 8 percent in Q3.",
"s2": "NRR held at 105 percent."}
def gather(sub_questions, search, fetch):
gathered, findings = {}, []
for sq in sub_questions:
for hit in search(sq, k=2):
gathered[hit["id"]] = fetch(hit["id"])
# model output (mocked): claim | source_id, incl. one INVENTED id (s9)
extracted = "Churn rose to 8 percent | s1\nMade up fact | s9"
for line in extracted.splitlines():
claim, _, sid = line.rpartition("|")
if sid.strip() in gathered: # reject invented citations
findings.append(Finding(claim.strip(), sid.strip()))
return findings, gathered
def search(q, k=2): return [{"id": "s1"}]
def fetch(i): return SOURCES[i]
fs, _ = gather(["why churn"], search, fetch)
print([(f.claim, f.source_id) for f in fs]) # only the s1 claim survives
Context: Beyond a real citation, the source must actually support the claim. The real agent asks a strict LLM judge; offline you approximate with a high content-overlap threshold that rejects sounds-plausible-but-unsupported claims.
Your task: Add a verifier: for each finding, check the source text supports the claim (offline: strong token overlap), keeping only verified findings.
Requirements:
- A support score is the fraction of the claim's content words present in the source
- A high threshold is required to count as supported
- Supported findings are kept; unsupported are dropped
- A near-miss that shares some words but isn't supported is rejected
- Runs entirely offline
💡 Hint: Use content words (length ≥ 3) and require most of the claim's words to appear in the source; a high threshold is what rejects the plausible-but-wrong claim.
Show solution
Design. The real agent asks a strict LLM judge; offline we approximate with a support score: " "the claim's content words must be largely present in the source. A high threshold rejects " "sounds-plausible-but-unsupported claims.
import re
def content(s): return set(re.findall(r"[a-z0-9]{3,}", s.lower()))
def supports(source, claim, thresh=0.8):
c = content(claim)
if not c: return False
return len(c & content(source)) / len(c) >= thresh
def verify_all(findings, gathered):
return [f for f in findings
if supports(gathered[f["id"]], f["claim"])]
gathered = {"s1": "Churn rose to 8 percent in Q3 due to onboarding."}
findings = [
{"claim": "churn rose to 8 percent", "id": "s1"}, # supported
{"claim": "churn fell to 2 percent", "id": "s1"}, # NOT supported
]
kept = verify_all(findings, gathered)
print([f["claim"] for f in kept]) # ['churn rose to 8 percent']
Context: Synthesis must be structurally incapable of citing anything unverified. Every sentence carries its source id, and when there are zero verified findings the agent must refuse rather than fabricate.
Your task: Build a synthesizer that renders each verified finding with its id and refuses on an empty verified set, then assert no unverified claim can appear.
Requirements:
- Only the verified list is passed into synthesis
- Each rendered claim carries its
[id] - An empty verified set returns a refusal, not invented text
- Assert every claim in the report is cited
- Assert the empty case refuses
💡 Hint: Pass only verified findings in and render claim [id]; short-circuit to an "insufficient evidence" string when the list is empty — the anti-hallucination backstop.
Show solution
Design. Pass only the verified list into synthesis and render each as 'claim [id]'. If the " "verified set is empty, the agent must say it cannot answer rather than invent — the anti-hallucination " "backstop.
def synthesize(verified):
if not verified:
return "Insufficient verified evidence to answer." # refuse, don't fabricate
lines = [f"- {f['claim']} [{f['source_id']}]" for f in verified]
return "Report:\n" + "\n".join(lines)
verified = [{"claim": "churn rose to 8 percent", "source_id": "s1"}]
report = synthesize(verified)
print(report)
assert "[s1]" in report # every claim is cited
assert synthesize([]).startswith("Insufficient") # empty -> refuse
print("ok")
Context: Real gathers return the same source under many sub-questions and can loop forever. Deduping by id plus two independent caps makes the loop bounded and idempotent — safe to retry.
Your task: Dedupe gathered sources by id and cap the loop by both maximum sub-questions and maximum total fetches so cost is bounded.
Requirements:
- Gathered sources are keyed by id, so duplicates collapse
- A breadth cap limits how many sub-questions are explored
- A total-fetch cap bounds overall work
- Both caps are respected simultaneously
- The result is the same regardless of duplicate hits (idempotent)
💡 Hint: A dict keyed by id gives free dedupe; enforce a sub-question slice and a running fetch counter, returning early when either cap is reached.
Show solution
Design. Gathered is a dict keyed by id -> natural dedupe. Two independent caps (breadth and " "total fetches) bound the loop; both must be respected. Bounded + idempotent = safe to retry.
def research_loop(sub_questions, search, fetch,
max_subs=3, max_fetches=5):
gathered, fetches = {}, 0
for sq in sub_questions[:max_subs]: # breadth cap
for hit in search(sq, k=3):
if fetches >= max_fetches: # total-fetch cap
return gathered
if hit["id"] not in gathered: # dedupe by id
gathered[hit["id"]] = fetch(hit["id"]); fetches += 1
return gathered
CORPUS = {f"s{i}": f"source {i}" for i in range(10)}
def search(q, k=3): return [{"id": "s1"}, {"id": "s1"}, {"id": "s2"}]
def fetch(i): return CORPUS[i]
g = research_loop(["a", "b", "c", "d"], search, fetch)
print(sorted(g)) # ['s1', 's2'] -- deduped, capped
Context: Shipping against a real model means structured outputs remove brittle string parsing: plan and verdict become schemas the model must fill, and a missing key falls back to the offline pipeline.
Your task: Use the Anthropic SDK's structured outputs to plan sub-questions and to get strict yes/no verdicts (needs a key), keeping the offline gather/verify pipeline as the fallback.
Requirements:
- Plan and Verdict are typed schemas the model fills
- The verify judge is deliberately strict (a boolean supported)
- Real calls use the Anthropic SDK
- A missing key falls back to the offline path
- Offline tests still pass with no key
💡 Hint: Wrap the real calls so an absent key routes to the keyword/overlap pipeline; structured outputs (Pydantic schemas) replace regex parsing of the model's text.
Show solution
Design. Structured outputs remove brittle string parsing: Plan and "
"Verdict are Pydantic schemas the model must fill. The verify judge is deliberately strict. "
"Wrap real calls so a missing key falls back to the offline path.
# --- real structured calls (needs creds / API key — Claude OR OpenAI) ---
# from pydantic import BaseModel
# class Plan(BaseModel): sub_questions: list[str]
# class Verdict(BaseModel): supported: bool
#
# Claude (Anthropic):
# import anthropic
# client = anthropic.Anthropic(); MODEL = "claude-opus-4-8"
# def plan(question):
# p = client.messages.parse(model=MODEL, max_tokens=400,
# system="Break into 3-5 focused sub-questions.",
# messages=[{"role":"user","content":question}], output_format=Plan)
# return p.parsed_output.sub_questions
#
# OpenAI: same Pydantic models, Responses API with structured parse:
# from openai import OpenAI
# client = OpenAI(); MODEL = "gpt-5.5"
# def plan(question):
# p = client.responses.parse(model=MODEL, max_output_tokens=400,
# instructions="Break into 3-5 focused sub-questions.",
# input=question, text_format=Plan)
# return p.output_parsed.sub_questions
# # verify() follows the same shape: swap messages.parse/output_format for
# # responses.parse/text_format, and parsed_output for output_parsed.
# --- offline fallback (runnable) ---
import os
def plan(question):
if os.environ.get("ANTHROPIC_API_KEY"):
raise RuntimeError("wire real_plan here") # never reached offline
return [f"{question} - detail {i}" for i in range(1, 4)] # deterministic
print(plan("why did churn rise?")) # 3 sub-questions, no key needed
✓ Checkpoint — done when…
- The agent plans, gathers, verifies, and synthesizes a cited report.
- It can't cite a source it didn't gather (tested with no key).
- Unsupported claims are dropped by the verify step.
- The eval passes: no invalid/irrelevant citations, good coverage.
| Dimension | Meets the bar | Above the bar (staff-level) |
|---|---|---|
| Citation integrity | Every claim in the report is traceable to a real gathered source; invented sources are impossible because citations are validated in code against what was actually retrieved. | Citations point to the specific passage supporting the claim, and a citation that doesn't support its claim is caught by an eval, not just its existence. |
| Adversarial verification | Each claim is checked against its source before it reaches the report; unsupported claims are dropped or hedged. | Verification is independent of generation (a separate pass with fresh context) so the agent can't rubber-stamp its own hallucination. |
| Coverage | The plan gathers enough distinct sources to cover the question; coverage is measured, not assumed from a single search. | Coverage rewards source diversity and cross-checking (a claim confirmed by two independent sources), not just source count. |
| Factual support | Evals measure what fraction of claims are actually supported by their cited sources, not just that citations exist. | Contradictions between sources are surfaced to the reader rather than silently resolved to whichever the model saw last. |
| Loop control & cost | The plan→gather→verify→synthesize loop is bounded so research terminates within a token/dollar budget instead of wandering. | The agent plans once and gathers in parallel where possible; depth is spent on the hard sub-questions, not uniformly. |
| Human-editable output | The draft is structured with claims and sources a human editor can check and finalize, not an unsourced wall of prose. | Confidence and open questions are flagged inline so the editor knows exactly where to spend their scrutiny. |
Score each row 0 (missing) / 1 (meets) / 2 (above). A passing research build is 9+/12 with citation integrity at 2 — a report with an invented source, or a claim its own cited source doesn't support, is an automatic fail, because in research a confident unsupported claim is the whole failure mode you were hired to prevent.
Knowledge check check yourself
What is the deep-research agent's 'golden rule', and which step enforces it?
Show answer
How does the code make it impossible for the agent to cite a source it never actually fetched?
Show answer
gathered set, and a claim is kept only if its source_id is in that set; invented citations are dropped in code. The citation set is controlled by your tools, not the model, so the model can't fabricate a source.