Agentic RAG Knowledge Assistant
Ordinary RAG retrieves once and answers. An agentic RAG assistant reasons about retrieval: it decides what to look up, searches multiple times, judges whether the results are good enough, reformulates when they aren't, and only answers when it has real evidence. It's the difference between a lookup and a researcher.
USE_REAL_API=1) you'll need an API key for whichever vendor you follow (toggle the Claude | OpenAI tabs on the pieces code):- Claude: an Anthropic API key (
ANTHROPIC_API_KEY) from console.anthropic.com +pip install anthropic - OpenAI: an OpenAI API key (
OPENAI_API_KEY) from platform.openai.com +pip install openai
What this project teaches you to design
- A retrieval loop: query → judge relevance → reformulate/re-search → answer.
- Query planning: decomposing a complex question into sub-queries.
- Self-grading of retrieved context, and abstaining when evidence is thin.
- Multi-source retrieval (docs, DB, web) chosen by the agent.
The brief advanced
You demo the bot and it dazzles. "What's our refund window?" — instant, cited, correct. Everyone nods, you ship it. Then the first real support ticket lands: "The customer bought under the 2023 policy but is asking about a product we discontinued — does the old warranty still apply?" Your bot retrieves the current policy, sees nothing about 2023 or discontinued products, and either shrugs or — worse — confidently makes something up. The demo questions and the real questions were never the same species.
Here's the pattern behind that failure, and it's the whole reason this project exists. A single-shot retriever is like sending an intern to the filing cabinet with one search term and no permission to come back. If the answer lives across three folders, or the first search term was slightly wrong, the intern returns with a partial folder and you get a partial (or invented) answer. A good researcher, by contrast, searches, reads what came back, notices what's missing, and searches again with a sharper query — looping until the evidence actually covers the question. That loop is the entire difference, and it's what "agentic" means here: not a smarter model, but a model given the authority to search more than once and to judge its own results.
Why does this matter for a design brief specifically? Because the temptation is to "fix" bad answers by swapping in a bigger model or a fancier embedding. Neither helps when the real problem is that the system only looked once. The common mistake teams make is treating retrieval quality as a model problem when it's a control-flow problem — the fix is a loop with a self-check, not a better one-shot. Everything below designs that loop.
"Our chatbot answers easy questions but falls apart on real ones." Single-shot RAG fails when the answer needs several lookups, when the first retrieval misses, or when the question spans multiple sources. An agentic RAG assistant plans its search, checks its own results, and iterates — so it handles the multi-hop, compound questions that a one-shot retriever gets wrong.
Read the two panels side by side, split by the dashed line: the left is today's single-shot bot, the right is the agentic assistant this project builds.
- The left (blue → red) path goes question → one search → and then answers straight off partial evidence, so you get a half-answer or an invented one — the red box is the failure the brief opens with.
- The right (amber) path adds a "check the gap" step after searching; the curved arrow back up is the re-search when something is still missing.
- The green "covered → answer" box only fires once the evidence genuinely covers the question — that gated exit is what stops the researcher from answering too early.
In short: single-shot answers after one search whether or not it has enough; the researcher only answers once a self-check says the evidence is complete.
1 · Discovery — where does plain RAG break? advanced
Before you build the loop, earn the right to build it. Agentic RAG is more expensive and slower than plain RAG — every extra retrieval is another round-trip and every self-grade is another model call. If you wrap the whole loop around questions that a single lookup already answered, you've added latency and cost and gotten nothing back. So the first design move isn't to build; it's to find the questions where a plain retriever actually breaks, and confirm there are enough of them to justify the machinery.
Think of it like deciding whether to hire a research analyst versus using a search box. For "what's the capital of France," the search box wins — instant, free, right. You only want the analyst for questions that need several steps: cross-referencing sources, chasing a lead the first search surfaced, reconciling documents that disagree. The table below is a triage of exactly that: which question shapes give agentic retrieval real leverage (multi-hop, missed-first-retrieval, cross-source) and which are a waste of it. Read the ⭐ ratings as "how much does the loop buy me here" — and notice the bottom row is a deliberate warning, not an oversight.
The mistake to avoid: assuming "agentic is better, so use it everywhere." The honest design isn't loop-everything — it's route. Cheap single-fact questions go straight to plain RAG; only the hard shapes enter the loop. Discovery is where you gather the evidence that this split is worth engineering, and it's what your evaluation in §6 will later have to prove.
Follow the question left to right: it hits a router in the middle that sends it down one of two paths depending on how hard it is.
- The blue "question" box on the left is any incoming user question; the amber "route?" box is the triage decision — this is the design move §1 is really about.
- The upper green path ("easy →") is cheap plain RAG: one lookup answers a single-fact question, so paying for the loop would only add latency and cost.
- The lower purple path ("hard →") is the agentic loop, reserved for the shapes the table below rates ⭐⭐⭐: multi-hop, first-retrieval-misses, and cross-source questions where searching more than once actually pays off.
In short: the value of agentic RAG isn't using it on everything — it's sending only the hard questions into the loop and letting the easy ones take the cheap path.
| Hard case | Agentic leverage |
|---|---|
| Multi-hop ("compare our policy to the 2024 one") | ⭐⭐⭐ high — plan + multiple retrievals |
| First retrieval misses the answer | ⭐⭐⭐ high — judge results, reformulate, retry |
| Answer spans docs + database + web | ⭐⭐ medium — agent picks the source |
| Simple single-fact lookup | ⭐ low — plain RAG is cheaper; don't over-loop |
2 · Architecture advanced
The moment you say "search again if the first result is bad," you've stopped drawing a pipeline and started drawing a loop — and loops are where systems get away from you. A straight line always terminates. A loop can spin forever, and if each spin costs an API call, "forever" costs money. So the architecture's real job isn't just wiring boxes together; it's deciding where the loop is allowed to go around again, and — just as importantly — where it must stop.
Picture the four moving parts as roles on a tiny research team. Plan is the lead who breaks a fat question into searchable sub-questions. Retrieve is the runner who goes to the shelf and brings back documents. Grade is the skeptical editor who reads what came back and asks one question — "does this actually answer what was asked, yes or no?" And the two exits are the editor's verdict: publish with citations, or refuse to publish because the evidence isn't there. The diagram below is exactly this team, drawn as a cycle with that skeptic in the middle.
Trace one question through the components left to right, then watch where it can bend back — this is the wiring behind the loop the next figure draws abstractly.
- The user question (left) feeds the agent loop (purple) — the only stateful component; inside it the amber
planandjudge_sufficiencysteps run under the MAX_STEPS leash. - The loop retrieves through the Project 9 search engine (blue), which embeds the sub-query and pulls top-k from the vector store (green) or from DB / web sources — the amber return arrow carries that context back up to the grader.
- Two terminal boxes on the right: a green "answer + citations" when the grade is sufficient, and a red "abstain" reached by the dashed path when the leash is exhausted — evidence never earns the exit, so the system refuses rather than guesses.
In short: the agent loop is the hub; the search engine + vector store + DB/web are the sources it queries, and the grader is the one component every retrieval must satisfy before an answer leaves the system.
- The self-grader is the load-bearing wall. Remove it and you don't have agentic RAG — you have plain RAG that happens to retrieve twice. The grade is what turns the arrow back.
- The loop-back is the whole point, and the whole danger. "Insufficient → reformulate → search again" is the behavior you're paying for, but it's also the behavior that can run away. It needs a leash (covered next in §3).
- Abstain is a first-class outcome, not an error. "I don't have enough to answer" is a correct result when the evidence genuinely isn't there — designing it in is what stops the system from hallucinating under pressure.
The common mistake here is to draw the happy path (question → answer) beautifully and treat the loop-back and the abstain exit as afterthoughts. They're the opposite: the happy path is what plain RAG already gave you, and these two edges are the entire reason this project earns the word "agentic."
This is the whole project in one picture: a loop, not a straight line. Read it left to right, but notice the pink arrow at the bottom that curls back — that return path is what makes this 'agentic' RAG instead of ordinary RAG.
- question (far left) — the user's real, possibly multi-part question comes in.
- plan query → sub-queries (purple box) — the agent breaks a hard question into smaller searchable pieces instead of searching for the whole thing at once.
- retrieve — it looks the sub-query up in a source: the Project 9 search engine, a database, or the web. This is the actual 'go find documents' step.
- grade context (yellow box, "good enough?") — the key step. The agent stops and asks itself: does what I just found actually answer the question?
- Two exits on the right. Green answer + cite if the evidence is sufficient; red abstain ("not enough evidence") if it never becomes sufficient.
- The pink loop-back arrow ("insufficient → reformulate & re-search") is the heart of it: when the grade says 'not enough', the agent rewrites its query and searches again, up to a fixed number of tries.
In short: ordinary RAG is just question → retrieve → answer. This diagram adds a self-check ("grade context") and a way to try again — that single feedback loop is the entire idea of agentic RAG.
A loop, not a line. The agent plans (decomposing the question into sub-queries), retrieves from the best source, then grades whether the context actually answers the question. If yes → answer with citations. If no → reformulate and search again. If it can't find evidence after a bounded number of tries → abstain. This is ReAct (E1) applied to retrieval.
3 · Risk & safety model advanced
The scariest bug in an agentic RAG system isn't a crash — it's a $400 invoice for a question nobody could answer. A retrieval loop that can decide to search again is, by construction, a loop that can decide to search again forever: the grade keeps coming back "insufficient," the agent keeps reformulating, and each pass burns tokens on a question the corpus simply doesn't contain the answer to. Nothing errors. It just spins, and you find out when the bill arrives. The autonomy you gave the loop in §2 is precisely the thing that has to be constrained here.
The mental model that keeps this straight: an autonomous loop is a dog off the leash. Off-leash is what makes it useful — it can chase the answer down paths you didn't script. But a dog off the leash near a road needs a fence and a whistle, or one day it doesn't come back. The controls in the table below are that fence: a hard cap on iterations (it can search at most N times, then it must stop), a token/cost budget (it can spend at most this much, then it must stop), and a graceful degrade path ("best effort, with a caveat") for when it hits either limit. None of these make the agent smarter — they make its failure bounded.
Read the table as two colors of risk. The 🔴 rows — answering without evidence, hallucinated citations — are trust failures: the system lies, and one confident wrong answer poisons user trust in every right one. The 🟠 rows — runaway loops, drifting reformulations — are resource failures: the system wastes money or wanders off-topic. The common mistake is to obsess over the trust failures (they're embarrassing) and forget the resource ones (they're just expensive) — but in production it's usually the unbounded loop that pages you at 3 a.m. Design both leashes in from the start; retrofitting a cost cap onto a live loop is a bad afternoon.
Compare the two panels side by side — the red one on the left is the danger, the plain one on the right is the fix.
- The left red panel is an unbounded loop: the dashed circle is "grade says insufficient → re-search" with no exit, so it can spin forever and burn cost — nothing errors, the bill just grows.
- The right panel adds the leash. The amber gate at the top checks two limits every pass — "step < MAX and still under the cost budget?" — before it's allowed to retrieve and grade again.
- The dashed "cap / budget hit" arrow is the escape hatch: when either limit is reached, the loop drops into the green box — abstain, or best-effort with a caveat — rather than looping on.
In short: the two 🟠 controls in the table below (hard iteration cap + cost budget) are exactly what converts the left panel into the right one — bounded failure instead of a runaway bill.
| Risk | Control |
|---|---|
| 🔴 Answering without sufficient evidence | Self-grading gate — must pass a relevance check; abstain otherwise |
| 🟠 Infinite / expensive retrieval loops | Hard cap on iterations + a token/cost budget (E2); degrade to "best effort + caveat" |
| 🟠 Reformulation drifting off-topic | Anchor every sub-query to the original question; log the trajectory |
| 🔴 Hallucinated citations | Citations must reference actually-retrieved chunks; verify before answering (Project 7) |
| 🟠 Wrong source chosen | Constrain tool choice; prefer authoritative internal sources over web |
4 · The retrieval loop, in code advanced
Every diagram you've seen so far collapses into about ten lines of Python — and it's worth seeing that collapse happen, because it kills a myth. Newcomers assume "agentic" means a heavyweight framework, a graph engine, some orchestration layer. It doesn't. The core of an agentic RAG system is a for loop with a range bound and an if that breaks early. If you can read a loop that stops after N tries unless it hits a good result sooner, you can read this whole system.
The shape to hold in your head as you read the snippet: it's a search-until-satisfied loop with a hard ceiling, exactly like retrying a flaky network call — try, check the result, retry with a tweak if it failed, but never more than a fixed number of times. Four verbs do the work — plan the sub-queries, retrieve the hits, judge_sufficiency on what came back, and reformulate when it's not enough — and the range(max_steps) is the leash from §3 made literal: the loop cannot run more than max_steps times, so "forever" is impossible by construction.
Watch one thing especially: the loop has two ways out and they mean opposite things. Returning early with a cited answer is success — the evidence cleared the bar. Falling out the bottom to abstain is honest failure — the leash ran out before the evidence did. A loop with only the success exit is the classic beginner bug: it either loops forever or, worse, returns a confident answer built on evidence it never actually verified. The abstain line is short, easy to skip, and the most important line in the file.
plan runs once; then retrieve → judge_sufficiency repeats inside range(max_steps). A "yes" grade exits green (cited answer); running out of steps falls through to the red abstain.
Follow it top-to-bottom, and note the loop is the middle band — the arrow curling back up on the right is the range(max_steps) cycle.
- plan(question) (purple, top) runs once to break the question into sub-queries before the loop starts.
- The loop body is retrieve (blue) then judge_sufficiency (amber) — the self-critique that decides everything.
- A "yes" grade exits left to the green
answer+cite; a "no" grade goes right toreformulateand the arrow loops back to retrieve again — but only while steps remain. - The dashed arrow down to the red
abstainbox is the second exit: whenrange(max_steps)is exhausted without a "yes", the loop falls through here — the short line that keeps the system honest.
In short: plan once, then loop retrieve→judge under a hard step cap; green exit = enough evidence, red exit = leash ran out.
Setup to run this snippet
class _Any:
'''stands in for any undefined demo value; supports call/attr/index/
iteration and basic arithmetic (as 0.7) so demo snippets run.'''
def __call__(self, *a, **k): return _Any()
def __getattr__(self, k): return _Any()
def __getitem__(self, k): return _Any()
def __iter__(self): return iter([])
def __len__(self): return 0
def __contains__(self, o): return True
def __enter__(self, *a): return _Any()
def __exit__(self, *a): return False
def __float__(self): return 0.7
def __int__(self): return 1
def __lt__(self, o): return True
def __gt__(self, o): return False
def __le__(self, o): return True
def __ge__(self, o): return False
def __add__(self, o): return o
def __radd__(self, o): return o
def __bool__(self): return True
def __repr__(self): return 'demo'
def __str__(self): return 'demo'
def abstain(*a, **k): # demo stub
return _Any()
def generate_answer(*a, **k): # demo stub
return _Any()
def judge_sufficiency(*a, **k): # demo stub
return _Any()
def next_query(*a, **k): # demo stub
return _Any()
def plan(*a, **k): # demo stub
return _Any()
def reformulate(*a, **k): # demo stub
return _Any()
def retrieve(*a, **k): # demo stub
return _Any()agentic_rag.py (shape)def answer(question, max_steps=4):
subqs = plan(question) # decompose (E1)
context = []
for step in range(max_steps): # the leash
hits = retrieve(next_query(subqs, context)) # P9 engine
context += hits
grade = judge_sufficiency(question, context) # self-critique
if grade.sufficient:
return generate_answer(question, context) # cited
subqs = reformulate(question, grade.gap) # try again
return abstain(question, context) # honest "not enough evidence"
Before any real files, this is the shape of the whole system in ten lines — a map you'll recognise in every file that follows. Don't run it; read it as pseudocode that names the four moving parts: plan, retrieve, judge_sufficiency, and reformulate.
def answer(question, max_steps=4):— the whole assistant is one function.max_steps=4is the leash: it can search at most four times, then it must stop. That cap is a safety feature, not a detail.subqs = plan(question)breaks the question into sub-questions;context = []is the empty notebook where retrieved facts pile up.for step in range(max_steps):repeats the search cycle up to four times. Each pass:retrieve(...)fetches hits,context += hitsadds them to the notebook, andjudge_sufficiency(...)asks 'is this enough?'.if grade.sufficient: return generate_answer(...)— the moment the evidence is good enough, it writes a cited answer and leaves the loop. Otherwisereformulate(question, grade.gap)rewrites the query to chase the missing piece and the loop goes round again.- If all four tries pass without ever being sufficient, the last line
return abstain(...)runs — an honest 'I don't have enough to answer'.
What the output means: Nothing prints — this is a skeleton to read. The real, runnable version is loop.py in Step 5, which follows this exact shape.
Try this: Find the four verbs — plan, retrieve, judge, reformulate — in the picture above. This code is that diagram written out. Everything after here just fills in each verb with real Python.
judge_sufficiency: an LLM call that reads the question and the retrieved context and answers "can this be answered from here — yes/no, and what's missing?" That grade drives the loop. Get it right and everything else follows.5 · Tool surface advanced
Give the agent a web-search tool "just in case," and one day it answers an internal-policy question by quoting a random blog it found online. That's the trap of the tool surface: every capability you hand the agent is a door it might walk through, and the ones that reach outside your trusted data are doors to answers you can't vouch for. So the tool surface isn't a menu of "everything we could wire up" — it's a deliberately short, deliberately typed list of what the agent is allowed to do, with the risky doors clearly marked.
A useful frame: think of the tools as the ingredients an agent is permitted to cook with, sorted by how much you trust the source. Your internal knowledge base and read-only database are the pantry — vetted, owned, safe to serve. The open web is the stuff a stranger left on the doorstep — sometimes exactly what you needed, sometimes spoiled, always to be labeled before it goes on the plate. The table below sorts the surface this way: 🟢 read-only internal sources you trust by default, and 🟠 the web tool that's fenced off with "prefer internal, label web." And notice judge_sufficiency sits in the list too — the self-grader is itself a tool, the one that decides when to stop reaching for the others.
Two design instincts to build here. First, read-only is a feature, not a limitation: a retrieval agent that can only read can't corrupt your data no matter how badly it reasons, which shrinks the blast radius of every other bug on this page. Second, the common mistake is adding tools because they're impressive rather than because a question needs them — every extra tool is another choice the agent can get wrong and another attack surface. Keep the surface small; make each entry earn its place against a real question shape from §1.
web_search) is fenced off as untrusted — prefer internal, and label anything the web returns.
Start at the amber box on top — the agent choosing a tool — and follow its two arrows down into the two trust tiers.
- The green box (left) is the trusted "pantry": the read-only tools you own —
search_knowledge,query_database, thejudge_sufficiencygate, and theplan / reformulatereasoning helpers. The solid "default →" arrow means the agent reaches here first. - The red box (right) is the untrusted "doorstep": the optional
web_search. The dashed "only if needed →" arrow means it's a fallback, and its output must be labeled because you can't vouch for it. - Notice
judge_sufficiencylives inside the pantry — the self-grader is itself a tool, the one that decides when the agent has enough and can stop reaching for the others.
In short: every tool is a door; the green ones are safe to open by default, the red one is fenced off — keep the surface small and trust-sorted.
| Tool | Does | Risk |
|---|---|---|
search_knowledge | Semantic/hybrid retrieval (Project 9) | 🟢 read-only |
query_database | Read structured facts (read-only SQL) | 🟢 read-only |
web_search (optional) | External info when internal is insufficient | 🟠 untrusted source — prefer internal, label web |
judge_sufficiency | Grade whether context answers the question | 🟢 the control gate |
plan / reformulate | Decompose & refine queries | 🟢 reasoning |
6 · Evaluation advanced
"It feels smarter" is not a metric, and it's how good agentic RAG systems get quietly killed. You built the loop, it demos well, everyone's impressed — and then someone asks "is it actually better than the plain RAG we already had, or just slower?" If you can't answer with a number, the honest answer is you don't know, and the loop's extra latency and cost become impossible to defend. Evaluation is where you convert a feeling into evidence.
The trap unique to this system is that a single accuracy number lies. Imagine a version that answers every multi-hop question perfectly but also confidently answers questions it should have abstained on — high accuracy, terrible in production. Or one that abstains constantly to stay "safe" — never wrong, never useful. You have to measure the loop along its own axes, and the table below lays them out: correctness on the hard multi-hop set (where plain RAG fails and this should win), faithfulness and citation-correctness (does every claim trace to retrieved evidence, or did it garnish?), abstention-correctness (does it say "I don't know" exactly when it should — not more, not less), and steps/cost per answer (is the loop looping more than the question needed?).
But the one measurement that decides whether this project should exist is the last row: uplift versus plain RAG. This is the researcher-versus-search-box test made quantitative — run both systems on the same question set and compare. If the loop only ties plain RAG, you're paying for machinery that buys nothing, and the right call is to route those questions to the cheaper path. The common mistake is skipping the baseline entirely and reporting the agentic system's numbers in isolation; a number with no baseline can't tell you if the loop earned its keep. Always measure against the thing it's supposed to beat.
Read it as a bar chart: taller bars are better scores, and each cluster compares plain RAG (blue) against agentic RAG (purple) on one kind of question.
- The left cluster (EASY · single-fact) shows the two bars at almost the same height — on simple lookups the loop wins nothing, so its extra latency and cost are pure waste. This is why §1 says route easy questions to the cheap path.
- The right cluster (HARD · multi-hop) is the point: the blue plain-RAG bar is short (it fails questions needing more than one lookup) while the purple agentic bar is tall.
- The double-headed "uplift" arrow between the two right bars is the single number that justifies the whole project — how much the loop beats the baseline where it matters.
In short: always measure against plain RAG; the loop earns its keep only if the hard-question gap (the uplift) is real, and it should be near-zero on easy ones.
| Eval | Measures |
|---|---|
| Answer correctness (multi-hop set) | Accuracy on questions that need >1 retrieval — where plain RAG fails |
| Faithfulness & citation correctness | Every claim grounded in retrieved evidence (Ch 5) |
| Abstention correctness | Abstains when the answer truly isn't retrievable |
| Steps / cost per answer | Efficiency — is it looping more than needed? |
| Uplift vs plain RAG | Prove the agentic loop beats single-shot on hard questions (and isn't wasteful on easy ones) |
7 · Phased rollout expert
The failure mode here is building the whole loop, shipping it, watching it misbehave, and having no idea which piece is at fault. Was it the planner splitting the question badly? The grader passing weak evidence? The reformulation drifting off-topic? When everything went live at once, you can't tell — you have to debug the whole machine as one opaque unit. Phased rollout is the antidote: turn the pieces on one at a time so that when quality moves, you know exactly which piece moved it.
Think of it as adding one instrument to a band and listening before adding the next. Phase 1 is plain RAG alone — one retrieve, one answer, with citations and an abstain path. That's not a throwaway; it's your baseline, the number every later phase has to beat (this is the uplift comparison from §6, built in from day one). Phase 2 adds just the grader and a single retry — the smallest possible taste of "agentic" — and you measure whether one self-check and one re-search actually lifts the hard questions. Only in Phase 3 do you turn on the full loop: planning, bounded iterations, multiple sources, the cost budget, and the routing that sends easy questions to the cheap path.
The discipline this enforces is that every rung earns the next one. If Phase 2's grader-plus-retry doesn't beat the Phase 1 baseline, you've learned something cheap and early — maybe your questions don't actually need the loop, or your grader isn't discriminating. The common mistake is skipping straight to Phase 3 because it's the "real" system; you get a system that's better or worse than the baseline and no way to attribute why. And the "Never" rung is not a phase but a permanent boundary — no answer without passing the sufficiency gate, no loop without a hard cap and budget — the two invariants from §3 that hold in every phase, including the ones you haven't built yet.
Read it bottom-to-top as a stack you build upward: the widest band at the bottom is what you ship first, and each phase above adds exactly one capability.
- Phase 1 (bottom) is plain RAG with an abstain path — deliberately drawn as the foundation because it's the baseline every later phase must beat.
- Phase 2 adds only the grader plus a single retry — the smallest taste of "agentic" — so you can measure whether one self-check actually lifts the hard questions.
- Phase 3 (top) turns on the full loop: planning, bounded iterations, DB/web sources, and the routing that sends easy questions to the cheap path.
- The red band underneath is the "Never" rung — the two invariants from §3 (no answer without the gate, no loop without a cap+budget) that hold in every phase, even ones you haven't built yet.
In short: add one instrument at a time and listen — if a phase doesn't beat the baseline below it, you've learned that cheaply, before shipping the whole machine.
Skills & course map expert
| Skill | Learn it in |
|---|---|
| RAG fundamentals & grounded answers | Ch 3 |
| Agentic RAG & GraphRAG | M3 |
| The retrieval engine | Project 9 |
| Loops, state, iteration caps | L5 |
| ReAct / query planning | E1 |
| Faithfulness + abstention evals | Ch 5 |
python3 --version). The loop, grader, and cap are ordinary control flow, so everything is tested offline with scripted stand-ins — no key needed until you wire the real model at the end.By the end you will have
- A bounded retrieve → grade → reformulate loop.
- A sufficiency grader that decides "answer" vs "search again".
- A hard step cap and a graceful abstention when evidence is thin.
- Six passing tests, including cap-enforcement and abstention, with no key.
How to use this page expert
Do the steps in order. terminal = commands to run; file = create that file with the exact contents. Expected output follows each command.
Step 1 · Project folder expert
terminalmkdir -p agentic-rag/tests
cd agentic-rag
# you are now inside agentic-rag/
Step 2 · Virtual environment expert
terminalpython3 -m venv .venv
source .venv/bin/activate
Step 2 — Windows (PowerShell)
terminalpy -m venv .venv
.venv\Scripts\Activate.ps1
(.venv) /Users/you/agentic-rag $
Step 3 · Install dependencies expert
terminalpip install pytest "anthropic>=0.40"
pip freeze > requirements.txt
Successfully installed anthropic-0.69.0 pytest-8.3.4 ...
Step 4 · The pieces the loop needs (as swappable functions) expert
Create pieces.py. The loop depends on three functions: retrieve, judge (the grader), and generate. Here we give real (mock-backed) versions and make them injectable so tests can script them. Paste the whole file.
agentic-rag/pieces.py
The retrieve / judge / generate pieces — in Claude or OpenAI
The corpus, retrieval, the Grade dataclass, the mock judge/generate, and the whole loop are vendor-neutral — only the two _real_* helpers change. Claude uses client.messages.create(...) → msg.content[0].text; OpenAI uses client.responses.create(...) → resp.output_text. Toggle the tabs.
pieces.py"""The retrieve / judge / generate pieces the loop orchestrates."""
import os
from dataclasses import dataclass
# A tiny corpus: each 'doc' is (id, text). The loop learns to combine facts.
CORPUS = {
"cur": "The current refund policy allows 30 days.",
"old": "The 2024 refund policy allowed 14 days.",
}
@dataclass
class Grade:
sufficient: bool
gap: str = ""
def retrieve(query: str) -> list[str]:
"""Return doc texts whose id-keyword appears in the query. Deliberately
naive so a first query finds only part of a multi-hop answer."""
hits = []
if "current" in query or "refund" in query: hits.append(CORPUS["cur"])
if "2024" in query or "old" in query or "previous" in query:
hits.append(CORPUS["old"])
return hits
def judge(question: str, context: list[str]) -> Grade:
"""Sufficiency grader. Mock rule: a 'compare' question needs BOTH the
current AND the 2024 fact. Real version calls the model (Step 8)."""
if os.environ.get("USE_REAL_API") == "1":
return _real_judge(question, context)
joined = " ".join(context)
if "compare" in question.lower():
has_cur = "current" in joined
has_old = "2024" in joined
if has_cur and has_old:
return Grade(True)
return Grade(False, gap="the 2024 policy" if has_cur else "the current policy")
return Grade(bool(context)) # simple question: any context suffices
def generate(question: str, context: list[str]) -> str:
if os.environ.get("USE_REAL_API") == "1":
return _real_generate(question, context)
return " | ".join(context) + " [grounded]" # mock answer
def _real_judge(question, context):
import anthropic, json
client = anthropic.Anthropic()
msg = client.messages.create(model="claude-opus-4-8", max_tokens=150,
system="Reply JSON {\"sufficient\":bool,\"gap\":str}. Does CONTEXT fully "
"answer QUESTION? If not, name what's missing in gap.",
messages=[{"role":"user","content":f"QUESTION:{question}\nCONTEXT:{context}"}])
d = json.loads(msg.content[0].text)
return Grade(d["sufficient"], d.get("gap", ""))
def _real_generate(question, context):
import anthropic
client = anthropic.Anthropic()
msg = client.messages.create(model="claude-opus-4-8", max_tokens=400,
system="Answer only from the context; cite it.",
messages=[{"role":"user","content":f"Context:{context}\nQ:{question}"}])
return msg.content[0].text
pieces.py"""The retrieve / judge / generate pieces the loop orchestrates."""
import os
from dataclasses import dataclass
# A tiny corpus: each 'doc' is (id, text). The loop learns to combine facts.
CORPUS = {
"cur": "The current refund policy allows 30 days.",
"old": "The 2024 refund policy allowed 14 days.",
}
@dataclass
class Grade:
sufficient: bool
gap: str = ""
def retrieve(query: str) -> list[str]:
"""Return doc texts whose id-keyword appears in the query. Deliberately
naive so a first query finds only part of a multi-hop answer."""
hits = []
if "current" in query or "refund" in query: hits.append(CORPUS["cur"])
if "2024" in query or "old" in query or "previous" in query:
hits.append(CORPUS["old"])
return hits
def judge(question: str, context: list[str]) -> Grade:
"""Sufficiency grader. Mock rule: a 'compare' question needs BOTH the
current AND the 2024 fact. Real version calls the model (Step 8)."""
if os.environ.get("USE_REAL_API") == "1":
return _real_judge(question, context)
joined = " ".join(context)
if "compare" in question.lower():
has_cur = "current" in joined
has_old = "2024" in joined
if has_cur and has_old:
return Grade(True)
return Grade(False, gap="the 2024 policy" if has_cur else "the current policy")
return Grade(bool(context)) # simple question: any context suffices
def generate(question: str, context: list[str]) -> str:
if os.environ.get("USE_REAL_API") == "1":
return _real_generate(question, context)
return " | ".join(context) + " [grounded]" # mock answer
def _real_judge(question, context):
import openai, json
client = openai.OpenAI()
resp = client.responses.create(model="gpt-5.5", max_output_tokens=150,
instructions="Reply JSON {\"sufficient\":bool,\"gap\":str}. Does CONTEXT fully "
"answer QUESTION? If not, name what's missing in gap.",
input=f"QUESTION:{question}\nCONTEXT:{context}")
d = json.loads(resp.output_text)
return Grade(d["sufficient"], d.get("gap", ""))
def _real_generate(question, context):
import openai
client = openai.OpenAI()
resp = client.responses.create(model="gpt-5.5", max_output_tokens=400,
instructions="Answer only from the context; cite it.",
input=f"Context:{context}\nQ:{question}")
return resp.output_text
This file builds the three workers the loop bosses around: retrieve (find documents), judge (the grader — decide if we have enough), and generate (write the final answer). Each has a fast mock version for learning offline and a real version that calls Claude — switched by one environment variable, so the loop's logic never changes.
CORPUSis a tiny fake library: two documents, a"cur"rent refund policy and an"old"2024 one. Answering "compare them" needs both — that's the multi-hop case the whole project is built to handle.@dataclass class Grade:is a small record with two fields:sufficient(a yes/no) andgap(what's still missing). The grader hands one of these back so the loop knows whether to stop or search again.retrieve(query)is deliberately naive: it only returns the current policy if the query mentions "current"/"refund", and only the 2024 policy if it mentions "2024"/"old"/"previous". So one query naturally finds only half a compare question — forcing the loop to go back for the rest.judge(question, context)is the grader. Its mock rule: for a"compare"question it needs both the current and the 2024 fact; if only one is present it returnsGrade(False, gap=...)naming what's missing. Any other question is satisfied by any context.generate(...)writes the answer (mock: just joins the facts and tags them[grounded]). The_real_judge/_real_generatehelpers do the same jobs by calling the model —client.messages.create(...)with Claude,client.responses.create(...)with OpenAI (toggle the tabs) — the grader asks for JSON{sufficient, gap}, and only runs whenUSE_REAL_APIis set to"1".
What the output means: Nothing runs yet — this file only defines the three pieces. The loop in Step 5 imports and orchestrates them.
Try this: Read retrieve and predict: for the query "what is the current refund policy?", which of the two docs comes back? (Just the current one — which is exactly why a single lookup can't answer a compare question.)
judge() is the piece that lets the system notice it only has half the answer (the "compare" case needs both policies) and go back for more. Everything else in the loop serves this decision.Step 5 · The bounded loop expert
Create loop.py. It plans, retrieves, grades, reformulates from the gap, and stops at MAX_STEPS — abstaining if evidence never suffices.
agentic-rag/loop.py
loop.py"""The agentic retrieval loop: retrieve -> grade -> reformulate, bounded."""
from pieces import retrieve as _retrieve, judge as _judge, generate as _generate
MAX_STEPS = 4
ABSTAIN = "I couldn't find enough to answer confidently."
def answer(question, retrieve=_retrieve, judge=_judge, generate=_generate):
"""Run the loop. The three pieces are injectable so tests can script
them. Returns dict with answer, steps used, and the trace."""
subquery = question
context: list[str] = []
trace: list[dict] = []
for step in range(MAX_STEPS): # the leash
hits = retrieve(subquery)
for h in hits:
if h not in context:
context.append(h)
grade = judge(question, context)
trace.append({"step": step, "subquery": subquery,
"hits": len(hits), "sufficient": grade.sufficient})
if grade.sufficient:
return {"answer": generate(question, context),
"steps": step + 1, "trace": trace, "abstained": False}
subquery = f"{question} (need: {grade.gap})" # reformulate
return {"answer": ABSTAIN, "steps": MAX_STEPS,
"trace": trace, "abstained": True}
if __name__ == "__main__":
r = answer("compare the refund policy to the 2024 version")
for t in r["trace"]:
print(t)
print("ANSWER:", r["answer"])
print("steps:", r["steps"])
This is the real, runnable version of the shape snippet — the agentic loop itself. It retrieves, grades, and reformulates from the gap, stopping either when the answer is sufficient or when it hits the step cap and abstains. Notice it takes the three pieces as arguments with defaults, so tests can swap in scripted fakes.
MAX_STEPS = 4andABSTAIN = "..."are the leash and the honest fallback message, defined once at the top where they're easy to find and change.def answer(question, retrieve=_retrieve, judge=_judge, generate=_generate):— the pieces default to the real ones frompieces.py, but a caller (like a test) can pass its own. This is dependency injection: same loop, swappable parts.subquery = questionstarts the search with the raw question;contextcollects facts andtracerecords what happened each step (for debugging and observability).- Inside
for step in range(MAX_STEPS):— retrieve hits, add only new ones to context (if h not in contextavoids duplicates), grade, and append a trace row. Ifgrade.sufficient, return the generated answer plussteps/trace. Otherwise rebuildsubqueryasf"{question} (need: {grade.gap})"— the gap steers the next search. - If the
forloop finishes without returning, all tries failed: the finalreturngives back theABSTAINmessage with"abstained": True. Theif __name__ == "__main__"block runs a demo compare question and prints the trace.
What the output means: Run directly, it prints one trace line per step and then the final answer — you'll see this in Step 6.
Try this: Change MAX_STEPS to 1 and re-run the demo. The compare question now abstains, because one search can't gather both policies — proof that the cap really does bound the work.
MAX_STEPS bounds it; the final return is the honest "not enough evidence" path. Both are tested in Step 7 — without them, an agentic-RAG system is a production incident waiting to happen.Step 6 · Run it — watch the loop reason expert
terminalpython loop.py
{'step': 0, 'subquery': 'compare the refund policy to the 2024 version', 'hits': 1, 'sufficient': False}
{'step': 1, 'subquery': 'compare the refund policy to the 2024 version (need: the 2024 policy)', 'hits': 1, 'sufficient': True}
ANSWER: The current refund policy allows 30 days. | The 2024 refund policy allowed 14 days. [grounded]
steps: 2
This is what python loop.py prints for the compare question — the loop reasoning out loud. Each dict is one pass through the search cycle, so you can literally watch it notice a gap and fix it.
- Step 0: subquery is the raw question;
'hits': 1means retrieve found only one document (the current policy);'sufficient': False— the grader correctly says 'this compare question isn't fully answered yet'. - Step 1: the subquery now ends with
(need: the 2024 policy)— the loop reformulated using the gap the grader reported. That extra phrase makesretrievefind the second document, and now'sufficient': True. - ANSWER line: both facts are joined together and tagged
[grounded]— the complete answer that needed two lookups to assemble. - steps: 2 — it used two of its four allowed tries. Efficient, and well under the leash.
What the output means: The two trace lines show the gap being detected then filled; the final two lines are the assembled grounded answer and the step count (2).
Try this: This is the payoff of the whole project: a plain RAG system would have stopped at Step 0 and answered with only half the story. Re-read Step 0's 'sufficient': False — that single 'no' is what saved the answer.
Step 7 · Tests (no key — scripted pieces) expert
Create tests/test_loop.py. By injecting scripted retrieve/judge/generate, we test the loop's control flow deterministically — including a grader that never says sufficient (to prove the cap + abstention).
agentic-rag/tests/test_loop.py
tests/test_loop.py"""Offline loop tests with scripted pieces — no key."""
from loop import answer, MAX_STEPS, ABSTAIN
from pieces import Grade
def test_answers_when_first_retrieval_suffices():
r = answer("q", retrieve=lambda q: ["fact"],
judge=lambda question, ctx: Grade(True),
generate=lambda question, ctx: "done")
assert r["steps"] == 1 and not r["abstained"]
def test_reformulates_then_answers():
calls = {"n": 0}
def judge(question, ctx):
calls["n"] += 1
return Grade(True) if calls["n"] >= 2 else Grade(False, gap="more")
r = answer("q", retrieve=lambda q: ["f"], judge=judge,
generate=lambda question, ctx: "done")
assert r["steps"] == 2 and not r["abstained"]
def test_stops_at_max_steps():
r = answer("q", retrieve=lambda q: ["f"],
judge=lambda question, ctx: Grade(False, gap="x"),
generate=lambda question, ctx: "done")
assert r["steps"] == MAX_STEPS
def test_abstains_when_never_sufficient():
r = answer("q", retrieve=lambda q: ["f"],
judge=lambda question, ctx: Grade(False, gap="x"),
generate=lambda question, ctx: "done")
assert r["abstained"] and r["answer"] == ABSTAIN
def test_trace_records_every_step():
r = answer("q", retrieve=lambda q: ["f"],
judge=lambda question, ctx: Grade(False, gap="x"),
generate=lambda question, ctx: "done")
assert len(r["trace"]) == MAX_STEPS
def test_real_multihop_uses_two_steps():
# uses the built-in mock pieces (no key)
r = answer("compare the refund policy to the 2024 version")
assert r["steps"] == 2 and not r["abstained"]
These six tests prove the loop's control flow without ever calling the API. The trick: each test passes its own tiny retrieve/judge/generate as arguments (using lambda, a one-line throwaway function), so the loop runs against scripted behaviour that's fast, free, and predictable.
test_answers_when_first_retrieval_suffices— a judge that always saysGrade(True)means the loop should finish in exactly 1 step and not abstain. This proves it doesn't waste loops when the answer is already there.test_reformulates_then_answers— a judge that returns False the first time and True the second (counting withcalls["n"]) proves the gap drives a second retrieval:steps == 2.test_stops_at_max_stepsandtest_abstains_when_never_sufficientuse a judge that always says False. The loop must stop atMAX_STEPSand return theABSTAINmessage — this is the safety leash under test.test_trace_records_every_stepchecks the trace has one entry per step (observability).test_real_multihop_uses_two_stepsuses the real built-in mocks (no scripting) to confirm the genuine compare flow takes 2 steps.
What the output means: Run with pytest, all six should report PASSED and end with 6 passed — see the next lab.
Try this: A lambda q: ["fact"] is just a mini-function: 'given a query q, return the list ["fact"]'. Try adding a seventh test where the judge says True on the third call, and assert steps == 3.
terminalpython -m pytest tests/ -v
tests/test_loop.py::test_answers_when_first_retrieval_suffices PASSED
tests/test_loop.py::test_reformulates_then_answers PASSED
tests/test_loop.py::test_stops_at_max_steps PASSED
tests/test_loop.py::test_abstains_when_never_sufficient PASSED
tests/test_loop.py::test_trace_records_every_step PASSED
tests/test_loop.py::test_real_multihop_uses_two_steps PASSED
6 passed in 0.05s
| Test | Proves |
|---|---|
| answers on first sufficiency | no wasted loops when the answer's already there |
| reformulates then answers | the gap drives a second retrieval |
| stops at MAX_STEPS | the leash holds — no infinite loop |
| abstains when never sufficient | honest "not enough" instead of a guess |
| trace records every step | the loop is observable/debuggable |
| real multi-hop uses 2 steps | the built-in mock genuinely does the multi-hop |
Step 8 · Go live with Claude (optional) expert
terminalexport ANTHROPIC_API_KEY="sk-ant-your-key-here"
export USE_REAL_API=1
python loop.py
# now judge() and generate() call Claude; the loop logic is unchanged
This flips the same loop from the offline mock to the real Claude model — without changing a line of loop.py. Two environment variables do it, which is exactly why the pieces were built with a mock/real switch earlier.
export ANTHROPIC_API_KEY="sk-ant-..."(orOPENAI_API_KEY="sk-..."for OpenAI) puts your secret key in the environment so the client can authenticate. It never appears in your code.export USE_REAL_API=1is the switch the pieces check: with it set,judgeandgeneratecall their_real_*versions (Claude) instead of the mocks.python loop.pythen runs the identical loop — same retrieve/grade/reformulate logic — but now the grading and answering are done by the real model.
What the output means: The loop behaves the same, but judge() and generate() now hit the real model API (Anthropic or OpenAI, depending on which tab's code you used). Costs real tokens; the loop structure is unchanged.
Try this: Run unset USE_REAL_API to go back to the free, offline mock. Being able to flip between mock and real with one variable — and keeping tests always on the mock — is a pattern you'll reuse in every LLM project.
loop.py run directly uses the real model. Return to offline mode with unset USE_REAL_API.Troubleshooting — every error you might hit expert
| What you see | What it means & the fix |
|---|---|
python3: command not found | Install Python; on Windows use py. |
No (.venv) | Re-run the Step 2 activate line. |
ModuleNotFoundError: loop / pieces | Run pytest from inside agentic-rag/. |
| Loop hits MAX every time | Grader too strict — check the "compare" branch returns Grade(True) once both facts are present. |
| Answers with thin evidence | Grader too lenient — require the gap to be genuinely filled. |
| Real mode: JSON parse error in judge | The model didn't return clean JSON — tighten the system prompt or use structured output. |
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: You can't claim the agentic loop is worth its cost until you have a number to beat. The baseline is plain retrieve-then-answer — but one that abstains instead of guessing, so it never hallucinates.
Your task: Build a single retrieve-then-answer function over a tiny corpus that answers when it retrieves something and returns a fixed abstain message when it retrieves nothing.
Requirements:
- A small in-memory corpus and a naive keyword
retrieve(query) - One retrieval call only — no loop, no reformulation
- When retrieval is empty, return a fixed abstain string rather than a guess
- When retrieval hits, return an answer grounded in the retrieved text
- Demonstrate one query that answers and one that abstains
💡 Hint: The whole point of the baseline is the abstain branch: gate on whether anything was retrieved before you compose an answer.
Show solution
The single-shot baseline with an abstain path (pure stdlib):
CORPUS = {"cur": "The current refund policy allows 30 days.",
"old": "The 2024 refund policy allowed 14 days."}
ABSTAIN = "I couldn't find enough to answer confidently."
def retrieve(query):
hits = []
if "current" in query or "refund" in query: hits.append(CORPUS["cur"])
if "2024" in query or "old" in query: hits.append(CORPUS["old"])
return hits
def plain_rag(question):
ctx = retrieve(question)
if not ctx:
return ABSTAIN # never guess
return " ".join(ctx) + " [grounded]"
print(plain_rag("what is the refund policy")) # answers from 'cur'
print(plain_rag("what is your uptime SLA")) # abstains
Plain RAG retrieves once and answers — and critically abstains rather than hallucinating when retrieval returns nothing. This is the baseline the agentic loop must beat on multi-hop questions; if the loop can't beat it, the added complexity isn't earning its keep.
Context: The core of an agentic loop is a gate that judges its own evidence. Before the loop can iterate intelligently it needs a grader that says "this context is (in)sufficient" and, when not, names what's missing.
Your task: Implement a judge that decides whether the current context can answer the question and, when it can't, names the gap — including a multi-hop "compare" question that needs two distinct pieces of context.
Requirements:
- A small
Graderecord carrying a sufficiency flag and a gap description - For a compare-style question, require both pieces of context to be present
- For a simple question, any relevant context is sufficient
- When insufficient, populate the gap with the specific missing piece
- Demonstrate the grader returning an insufficient verdict that names the gap
💡 Hint: A dataclass with sufficient and gap is enough; the compare case is just "are both required facts in the joined context?"
Show solution
The grader that drives the loop (pure stdlib, runnable):
from dataclasses import dataclass
@dataclass
class Grade:
sufficient: bool
gap: str = ""
def judge(question, context):
joined = " ".join(context)
if "compare" in question.lower(): # needs both policies
has_cur = "current" in joined
has_old = "2024" in joined
if has_cur and has_old:
return Grade(True)
return Grade(False, gap="the 2024 policy" if has_cur else "the current policy")
return Grade(bool(context))
print(judge("compare refund to 2024", ["The current refund policy allows 30 days."]))
# Grade(sufficient=False, gap='the 2024 policy')
The grader is what makes RAG agentic: instead of answering from whatever came back, it asks "can this actually answer the question?" and, when not, names the missing piece. That gap string becomes the next query — the loop reasons about retrieval, not just runs it.
Context: This is the milestone that makes the assistant agentic: retrieve, grade, and on insufficiency reformulate the query with the named gap and try again — all under a hard step leash so it can never loop forever.
Your task: Combine retrieve and judge into a bounded loop that reformulates using the grader's gap, accumulates context across steps, and abstains if it never becomes sufficient — solving a multi-hop question in two steps.
Requirements:
- A hard
MAX_STEPScap bounds the loop - Each iteration retrieves, adds new hits to accumulated context, then grades
- On insufficiency, reformulate the subquery using the grader's named gap
- On sufficiency, return the answer plus the step count and a trace
- If the cap is reached without sufficiency, return the abstain result
- A multi-hop question is shown resolving in two steps
💡 Hint: Loop with for step in range(MAX_STEPS) and keep a running context list; the reformulated subquery is just the question plus the gap the grader named.
Show solution
The bounded loop — the project's core (pure stdlib, runnable):
from dataclasses import dataclass
@dataclass
class Grade:
sufficient: bool; gap: str = ""
CORPUS = {"cur": "current: 30 days", "old": "2024: 14 days"}
MAX_STEPS, ABSTAIN = 4, "couldn't find enough"
def retrieve(q):
h = []
if "current" in q or "refund" in q: h.append(CORPUS["cur"])
if "2024" in q or "old" in q: h.append(CORPUS["old"])
return h
def judge(question, ctx):
j = " ".join(ctx)
if "compare" in question.lower():
return Grade(True) if ("current" in j and "2024" in j) else \
Grade(False, "the 2024 policy" if "current" in j else "the current policy")
return Grade(bool(ctx))
def answer(question):
subquery, context, trace = question, [], []
for step in range(MAX_STEPS): # THE LEASH
for h in retrieve(subquery):
if h not in context: context.append(h)
g = judge(question, context)
trace.append({"step": step, "sufficient": g.sufficient})
if g.sufficient:
return {"answer": " | ".join(context), "steps": step + 1, "trace": trace}
subquery = f"{question} (need: {g.gap})" # REFORMULATE
return {"answer": ABSTAIN, "steps": MAX_STEPS, "trace": trace}
print(answer("compare the refund policy to the 2024 version"))
The loop plans, judges, and reformulates using the named gap — solving a two-hop question that plain RAG cannot. The MAX_STEPS leash is non-negotiable: bounded autonomy is the safety property that keeps a self-directing loop from running forever.
Context: The scripted grader proves the loop's shape; production grades with a model. The trick is to keep the exact same Grade interface so the loop code doesn't change at all when you swap the judge.
Your task: Show the real LLM judge: call Claude, force a strict JSON verdict, and return the same Grade object the scripted judge did so the loop is untouched. Mark this rung as needing an API key.
Requirements:
- Call the model API for the grade (Claude's
messages.createor OpenAI'sresponses.create) - A system instruction forces a reply of strict JSON with a sufficiency flag and gap
- Parse the JSON and return the same
Gradetype the stub returned - The loop from the previous rung requires no changes to use it
- Clearly label the rung as requiring
ANTHROPIC_API_KEY
💡 Hint: Hold the interface fixed: whatever the model returns, adapt it into the same Grade(sufficient, gap) the loop already consumes.
Show solution
The real judge — needs pip install anthropic + ANTHROPIC_API_KEY (or openai + OPENAI_API_KEY — see the OpenAI variant in the comment below), a drop-in for the stub:
import json
from dataclasses import dataclass
@dataclass
class Grade:
sufficient: bool
gap: str = ""
def real_judge(question, context):
import anthropic
client = anthropic.Anthropic() # reads ANTHROPIC_API_KEY
msg = client.messages.create(
model="claude-opus-4-8",
max_tokens=150,
system='Reply ONLY JSON: {"sufficient": bool, "gap": str}.',
messages=[{"role": "user",
"content": f"QUESTION: {question}\nCONTEXT: {context}"}],
)
d = json.loads(msg.content[0].text)
return Grade(bool(d["sufficient"]), d.get("gap", ""))
# Same Grade return type as the stub -> the loop code doesn't change.
#
# OpenAI drop-in (needs OPENAI_API_KEY): swap the client + call, parse the same JSON:
# import openai; client = openai.OpenAI()
# resp = client.responses.create(model="gpt-5.5", max_output_tokens=150,
# instructions='Reply ONLY JSON: {"sufficient": bool, "gap": str}.',
# input=f"QUESTION: {question}\nCONTEXT: {context}")
# d = json.loads(resp.output_text)
Because the real judge returns the identical Grade, it swaps in behind the same interface the scripted tests exercise — the bounded loop is untouched. Forcing strict JSON output keeps the grader machine-parseable so a malformed judge response fails loudly instead of silently mis-driving the loop.
Context: An agentic loop that costs more must earn it. This milestone proves the loop wins on multi-hop questions while watching for the opposite failure: burning extra steps on easy questions it should answer in one.
Your task: Build an evaluation that compares agentic vs plain-RAG accuracy on a labelled set AND measures average steps per answer, then report the accuracy uplift and the average step count.
Requirements:
- A labelled set of
(question, correct_answer)cases including multi-hop ones - Run both the plain and agentic functions over the identical set
- Score accuracy for each and compute the uplift between them
- Accumulate steps taken by the agent and report the average
- Show the agent winning on multi-hop without inflating steps on easy questions
💡 Hint: Report two things side by side — accuracy uplift and average steps — so a win on hard questions and over-looping on easy ones are both visible.
Show solution
The eval that justifies the complexity (pure stdlib, runnable):
def evaluate(cases, plain_fn, agent_fn):
plain_correct = agent_correct = total_steps = 0
for q, correct in cases:
if plain_fn(q) == correct: plain_correct += 1
r = agent_fn(q)
if r["answer"] == correct: agent_correct += 1
total_steps += r["steps"]
n = len(cases)
return {"plain_acc": plain_correct/n, "agent_acc": agent_correct/n,
"uplift": (agent_correct - plain_correct)/n,
"avg_steps": total_steps/n}
# stubbed fns: plain misses multi-hop, agent solves in 2 steps, easy in 1
cases = [("multi-hop", "AB"), ("easy", "A")]
plain = lambda q: "A" if q == "easy" else "wrong"
agent = lambda q: {"answer": "AB" if q=="multi-hop" else "A",
"steps": 2 if q=="multi-hop" else 1}
print(evaluate(cases, plain, agent)) # uplift on multi-hop, avg_steps low
Two metrics keep the loop honest: uplift proves it beats plain RAG where retrieval must iterate, and steps-per-answer catches the opposite failure — burning extra calls looping on easy questions it should answer in one shot. A good agentic RAG improves hard cases without inflating cost on easy ones.
Context: In production the real risk isn't a wrong answer — it's an expensive infinite loop. As the owner you add a second leash: a per-answer cost budget on top of the step cap, so either limit can stop the run.
Your task: Add a per-answer cost budget alongside MAX_STEPS so the loop stops and abstains when either the step cap or the dollar/token budget is hit — modelling the dual leash.
Requirements:
- Constants for the step cap, a max cost, and a per-step cost estimate
- Accumulate spend each iteration and check it before doing more work
- Abstain with a distinct message when the cost budget trips
- Abstain with a distinct message when the step cap trips
- Demonstrate that whichever leash is tighter stops execution first
💡 Hint: Track a running spent total and test it at the top of each iteration; the two leashes are independent, so return a different abstain reason for each.
Show solution
The dual leash — steps AND cost budget (pure stdlib):
MAX_STEPS = 4
MAX_COST = 0.05 # $ per answer ceiling
COST_PER_STEP = 0.015 # retrieve + judge + generate, illustrative
def answer_bounded(retrieve, judge, generate, question):
context, spent = [], 0.0
for step in range(MAX_STEPS):
spent += COST_PER_STEP
if spent > MAX_COST: # cost leash trips first
return {"answer": "abstain (cost budget hit)", "steps": step, "spent": spent}
context += retrieve(question)
if judge(question, context):
return {"answer": generate(question, context),
"steps": step + 1, "spent": round(spent, 3)}
return {"answer": "abstain (step cap hit)", "steps": MAX_STEPS, "spent": spent}
# never-sufficient judge -> whichever leash is tighter stops it
print(answer_bounded(lambda q: ["x"], lambda q,c: False, lambda q,c: "ans", "q"))
Industry scenario: a support RAG loop hits a class of unanswerable questions and, without a budget, would retry until it drained the token quota. Two independent leashes — a step cap and a per-answer cost budget — guarantee it stops and abstains. Bounded autonomy plus a cost ceiling is what makes an autonomous loop safe to run on real traffic.
✓ You are done when…
python loop.pyanswers the compare question in 2 steps with both facts.python -m pytest tests/ -vshows 6 passed.- You can explain why the cap and abstention are mandatory.
- (Optional) The real model plugs into the same loop via one env var.
agentic-rag/
├─ .venv/
├─ requirements.txt
├─ pieces.py (retrieve, judge, generate — mock + real)
├─ loop.py (the bounded retrieve->grade->reformulate loop)
└─ tests/
└─ test_loop.py (6 offline tests)
| Dimension | Meets the bar | Above the bar |
|---|---|---|
| The loop is bounded | A hard iteration cap and a token/cost budget exist; the agent cannot loop forever. | On budget exhaustion it degrades to an honest best-effort-with-caveat, not a crash or a silent stall. |
| Self-grading gate works | judge_sufficiency drives the loop: it answers only when evidence is sufficient, else re-searches or abstains. | The grader is itself evaluated — you can show it abstains correctly on unanswerable questions. |
| Faithfulness & citations | Every claim is grounded in actually-retrieved chunks; citations reference real hits, not invented ones. | Citation correctness is measured on a set; a verification step rejects any answer with unsupported claims. |
| Multi-hop correctness | Accuracy is measured on questions that need >1 retrieval — where single-shot RAG fails. | A multi-hop eval set with graded answers; you can state accuracy and the failure taxonomy. |
| Justified vs plain RAG | The agentic loop is compared against single-shot RAG as a baseline. | Easy questions route to plain RAG; the loop is reserved for hard ones and the split is measured. |
| Efficiency observed | Steps and cost per answer are logged; you know the average and the tail. | Cost/steps are budgeted per query class; a runaway trajectory is caught and capped, not discovered on the bill. |
Score each row 0 (missing) / 1 (meets) / 2 (above). 0–4: a prototype — keep building. 5–8: a solid build you could take to review. 9–12: staff-level — production-defensible. Any dimension at 0 blocks shipping regardless of the total.
Knowledge check check yourself
What single component turns ordinary RAG into agentic RAG, and how does it drive the loop?
Show answer
judge_sufficiency grader: an LLM call that reads the question and retrieved context and answers yes/no whether it can be answered, naming what's missing. That grade decides whether to answer, reformulate and re-search, or abstain — everything else serves that decision.Why does the loop need both a hard MAX_STEPS cap and an abstention path rather than just searching until it succeeds?