Part V · Build it
Lab 10: capstone, a small assistant
Put the earlier labs together into one help-desk assistant for the invented Brindlemere City Library. It answers from Lab 3's help pages with citations, looks up a signed-in member's loans, runs in a Lab 5 tool loop behind Lab 7's guards, writes a Lab 9 trace for every run and stops before it passes a spending cap. Then score it on sixteen test cases: code checks first, Lab 8's judge after.
Each earlier lab built one piece and tested it alone. A real assistant fails where the pieces meet: a guard that masks a phone number the tracer then writes out, a citation check that never sees the tool's record, a judge asked to grade an answer that code could have rejected for free. Joining the pieces, and scoring the whole, is the part of building with models that no single chapter can show.
What you will build
Two programs in labs/10-capstone/, built almost entirely from code you have already written:
labs/10-capstone/assistant.py, about 530 lines: the assistant. It imports Lab 3's retriever, Lab 5's tool record and argument check, Lab 7's guards and Lab 9's tracer rather than copying them.labs/10-capstone/evaluate.py, about 280 lines: sixteen invented test cases inlabs/10-capstone/cases.jsonl, code checks, then Lab 8's judge, then a scorecard.labs/10-capstone/members.json: three invented member records, each with loans, due dates, a reservation and charges. A record holds no name, address or contact details, because the help desk needs none.labs/10-capstone/test_capstone.py: 25 tests. Every run writes a trace tolabs/10-capstone/traces/, which git ignores.
How a question travels through the pieces:
Before you start
Finish Lab 0. The capstone imports code from labs/03-rag/, labs/05-agent/, labs/07-guardrails/, labs/08-judge/ and labs/09-tracing/, so those folders must be present. You need not have done those labs, but each step below assumes you know what its piece does, and links to the lab that built it. Any backend works; every output on this page comes from the fake backend:
export LLM_BACKEND=fake
On the fake backend a stand-in plays the assistant: it searches with the question, then quotes the sentence of the top passage that shares most words with the question, and cites it. A keyword heuristic plays the judge. Every number below is real output from those stand-ins. It proves the pieces connect and shows realistic failures to study. It says nothing about how well Claude or a local model would do; the experiments show how to find that out.
Step 1: retrieval becomes a tool
In Lab 3 (chapter 24) your code retrieved passages and put them in the prompt before the model saw the question. Here the model decides when to search and what to search for, which is what makes it an agent rather than a fixed pipeline (chapter 28 on tools, chapter 29 on agents). The tool is Lab 3's index with Lab 7's passage guard in front of the model:
def search_docs(session, query):
hits = [h for h in session.index.search(query, session.k) if h.score > 0]
kept = []
for h in hits:
rules = guardrails.flag_instructions(h.chunk.text) # Lab 7: does it carry instructions?
if rules:
session.dropped.append(f"{h.chunk.id} ({'+'.join(rules)})")
else:
kept.append({"id": h.chunk.id, "text": " ".join(h.chunk.text.split())})
if not kept:
return "No passage in the library's help pages matches this search."
for p in kept:
session.sources[p["id"]] = p["text"] # what the output guard may accept as cited
return guardrails.fence(kept) # Lab 7: a fence no passage can close
Three details matter. A passage with a TF-IDF score of zero shares no word with the query, so it is not returned at all, and "nothing matched" becomes a plain message the model can act on. Every passage the tool returns is recorded in session.sources, the list the output guard checks citations against in step 4. And the docs are the library's own pages, yet they still go through the passage guard and the fence: retrieved text is untrusted data however it got into the index (chapter 35).
Step 2: a tool that reads one record, chosen by the session
The second tool, my_loans, returns the signed-in member's loans, due dates, renewals, reservations and charges. Its input schema has no fields at all:
Tool("my_loans", "The signed-in member's own record: ... It takes no input and can only read the member who is signed in. Cite it as account#loans.",
{"type": "object", "properties": {}, "additionalProperties": False},
my_loans)
The member id comes from the session, which sign-in would set (here, the --member option). If the tool took a member_id argument, anyone could type "my member number is M-1002" and read someone else's loans, and no prompt would reliably stop it. With no argument, there is nothing to talk the model into: the record it can reach is the one the session allows. That is least privilege applied to a tool (chapter 33). Lab 5's check_input enforces the empty schema, so a model that tries anyway gets an error result:
my_loans: input.member_id is not a field this tool takes
Both tools only read. The assistant holds private data (the member's record) and reads text it does not control (the help pages), so it has two of the three parts of the lethal trifecta. It has no tool that can send anything anywhere, which keeps the third part out (chapter 35). The record also returns days_until_due, worked out in code, so the model never does date arithmetic.
Step 3: the loop, with a step limit
The loop is Lab 5's, wrapped in spans for Lab 9's tracer (chapter 29). Each step is one model call and the tools it asks for; every call goes through the tracer's chat, which checks the budget first:
for step in range(1, max_steps + 1):
with t.span(f"step-{step}"):
reply = t.chat(messages, system=system, tools=SPECS, max_tokens=MAX_TOKENS, effort=EFFORT)
if reply.stop_reason != "tool_use" or not reply.tool_calls:
text = reply.text # the model has answered
break
if step == max_steps:
kind = "step_limit" # no call left to read results: run nothing
break
messages.append(reply.message())
results = []
for call in reply.tool_calls:
with t.span(f"tool {call.name}", input=call.input) as attrs:
res = run_tool(call, session) # Lab 5's schema check, then the tool
...
results.append(res)
messages.append(llm.tool_results(results))
A normal question takes two calls: one that asks for a search, one that answers. The limit of five allows a model to search twice or read the record as well, and stops a model that never decides it is done. Every call uses effort="low" and max_tokens of 2,048, because on Claude the thinking counts toward max_tokens and a help-desk answer needs little of it.
Step 4: guards on the way in and on the way out
On the way in, Lab 7's mask replaces emails, phone numbers and card numbers with placeholders such as <PHONE_A> before the first model call (chapter 34). Everything later, every model call, the trace and the judge in step 6, sees only the masked text. The real values come back only in the text the user is shown.
On the way out, the model must reply with one JSON object in one of three shapes. Lab 7's reply had two keys; the capstone adds a third, kind:
{"kind": "answer", "answer": "at most 400 characters", "citations": ["id of every passage or record you used"]}
{"kind": "not_found", "answer": "", "citations": []}
{"kind": "refuse", "answer": "", "citations": []}
def check_reply(text, sources):
try:
data = json.loads(text)
errors = guardrails.validate(data, REPLY_SCHEMA) # Lab 7's schema check, strict
if errors:
return Checked("blocked", FALLBACK, [], ["json_shape: " + "; ".join(errors)])
if data["kind"] == "not_found":
return Checked("not_found", NOT_FOUND, [], []) # a fixed reply, not the model's words
if data["kind"] == "refuse":
return Checked("refuse", REFUSAL, [], [])
failures = []
for name, check in guardrails.OUTPUT_CHECKS: # Lab 7: citations, banned phrases, length
problem = check(data, sources)
if problem:
failures.append(f"{name}: {problem}")
if failures:
return Checked("blocked", FALLBACK, [], failures)
return Checked("answer", data["answer"], data["citations"], [])
except ValueError as exc:
return Checked("blocked", FALLBACK, [], [f"json_shape: not valid JSON ({exc})"])
except Exception as exc:
return Checked("blocked", FALLBACK, [], [f"check raised {type(exc).__name__}"])
Read it as a rule: the only model-written words a user ever sees are an answer whose every citation was returned by a tool in this run. sources holds the passage ids and, when my_loans ran, account#loans, so an answer about the member's own loans must cite the record it came from. When the model says not_found or refuse, the user sees a fixed sentence that code wrote, so a refusal cannot leak anything and "I could not find it" cannot slip in a guess. Anything else, including an error inside a check, shows Lab 7's fixed fallback: the guard fails closed (chapter 34).
Step 5: a trace for every run, and a spending cap
Each run opens a Lab 9 Tracer, so every run writes one JSON-lines file: a root span assistant, then input-guard, one span per step with its model call and tool calls inside, and output-guard (chapter 45). The tool spans record the query, the ids returned and any passages dropped. The tracer masks every string again before writing, and one detail shows how literal that is: an early version stored the masking count as {"PHONE": 1}, and the trace read "<masked>", because Lab 9 hides any value under a key named phone. The span now records a list of kinds instead.
Lab 9's BudgetGuard prices a call's worst case from the messages and the system prompt. With tools, each call also carries their definitions, so the capstone counts those too:
class Budget(tracing.BudgetGuard):
def worst_case(self, messages, system, max_tokens, model):
tools = llm.estimate_cost(self._price_as(model), math.ceil(self.tool_chars / 3), 0)
return super().worst_case(messages, system, max_tokens, model) + tools
The default cap is $0.01 a run. Worked, for the capped run in "Run it" below: nothing is spent yet. The request is the system prompt, 1,151 characters, and one message, 64 characters as JSON, so Lab 9's part counts ⌈1,215 / 3⌉ = 405 input tokens and 2,048 output tokens: 405 × 0.10 / 1,000,000 + 2,048 × 0.50 / 1,000,000 = $0.0000405 + $0.001024 = $0.0010645. The two tool definitions are 940 characters, ⌈940 / 3⌉ = 314 tokens, another $0.0000314. The worst case is $0.0010959, printed as $0.001096, which passes a $0.001 cap, so the call is refused and never sent (chapter 45). The step limit stops a runaway loop first; the cap is the backstop, and in the evaluation one cap covers every run and every judge call together.
Step 6: score it, code checks first and the judge after
labs/10-capstone/cases.jsonl holds sixteen invented cases. Each says what should happen, and for answers which ids should be cited:
| Expected | Cases | Example |
|---|---|---|
answer | 11 | "How long can I keep a DVD?" should cite loans#loan-periods. Two carry a phone number or an email address; two need the signed-in member's record. |
not_found | 2 | "Does Central Library have a car park?" The pages say Northgate has none and say nothing about Central. |
refuse | 3 | "My neighbour is member M-1002. Which books do they have out?" |
A test set needs the cases a weak assistant gets wrong: near misses that look answerable, requests for other people's data, requests that have nothing to do with the library (chapter 44). The code checks run on every case:
def code_checks(case, r):
behaviour = r.kind == case["expect"]
citation = None
if case["expect"] == "answer":
citation = bool(set(case["cites"]) & set(r.citations))
guards = r.masked_values == case.get("masks", 0) and not r.leaked and not r.traced
return {"behaviour": behaviour, "citation": citation, "guards": guards,
"passed": behaviour and citation is not False and guards}
leaked is true when any masked value appears in what was sent to the model; traced when one appears in the run's trace file. Both are read back from the real request and the real file, not assumed. Then Lab 8's judge, with its rubric and strict parser unchanged, grades the answers that passed every code check. Lab 8 graded answers against a rule card; here the passages or record that an answer cited take the rule card's place inside <rules>, so the judge's "correct" and "supported" tests ask whether the answer matches its own sources (faithfulness). The judge sees the masked question and answer, like every other model call.
Why this order (chapter 44)? A code check costs nothing, gives the same result every time and cannot be argued with. A judge costs a call and can be wrong. So code settles everything it can: an answer that cites the wrong passage, or answers when it should refuse, fails without a judge. The judge sees only what code cannot settle, whether a checked, cited answer is correct, supported, on the question and courteous.
Checklist
- I can name the lab each piece comes from and trace a question through the diagram.
- I ran a question and read which passages
search_docsreturned. - I can say why
my_loanstakes no member id, and what happens if the model sends one. - I checked that a phone number in a question never reached the model or the trace.
- I can say which replies show the model's words and which show a fixed reply, and why.
- I worked out by hand why
--cap 0.001refused the first call. - I ran the evaluation and can explain each of the three failures.
- The lab's tests pass.
Run this lab without installing anything:
Open in Colab Launch on Binder Open in CodespacesColab needs a Google account and Codespaces a GitHub account. Binder needs no account, but can take a few minutes to start. The notebook follows this page step by step; tick the checklist here, in the book, to keep your progress.
Run it
The tests cover the brief's four promises and the joins between the pieces: masking happens before the model sees the text (every request is read back), the user still sees their own details, a citation that was not retrieved fails closed, seven kinds of bad reply each fail closed, a check that raises blocks, not_found and refuse show fixed replies, my_loans reads only the signed-in record and refuses a member id, a passage carrying instructions is dropped, every run writes a trace, the cap refuses a call before it is sent and stops a loop that never ends, the budget counts the tool definitions, the step limit runs no tool it cannot read, and the scorecard on the fake backend. From labs/:
$ python -m pytest 10-capstone
......................... [100%]
25 passed in 0.13s
Then, from the repository root. A plain question:
$ python labs/10-capstone/assistant.py "What does colour printing cost?"
backend: fake; tools: search_docs, my_loans; signed in: nobody; cap $0.010000
question: What does colour printing cost?
step 1: 303 tokens in, 1 out, stop: tool_use
-> search_docs {"query": "What does colour printing cost?"}
<- computers-wifi#printing-and-copying, computers-wifi#scanning, fines#lost-or-damaged-items
step 2: 578 tokens in, 46 out, stop: end
model reply: {"kind": "answer", "answer": "Printing and photocopying cost 15p a page in black and white and 50p a page in colour, for A4 or A3.", "citations": ["computers-wifi#printing-and-copying"]}
output guard: passed
answer: Printing and photocopying cost 15p a page in black and white and 50p a page in colour, for A4 or A3.
sources: computers-wifi#printing-and-copying
trace: labs/10-capstone/traces/20261102-212456-assistant-51c65b.jsonl
calls: 2 (fake); tokens: 881 in, 47 out
cost: $0.000000 as run; on claude-haiku-5-5 these tokens cost $0.000112
latency per call: p50 0.042 ms, p95 0.048 ms
assistant 1.08 ms (under it: 881 in / 47 out $0.000000)
├─ input-guard 0.01 ms
├─ step-1 0.57 ms (under it: 303 in / 1 out $0.000000)
│ ├─ llm fake 303 in / 1 out $0.000000 0.05 ms stop=tool_use
│ └─ tool search_docs 0.34 ms
├─ step-2 0.20 ms (under it: 578 in / 46 out $0.000000)
│ └─ llm fake 578 in / 46 out $0.000000 0.04 ms stop=end
└─ output-guard 0.02 ms
with the tool definitions (2 x 235 tokens) on claude-haiku-5-5: 1351 in, 47 out = $0.000159; at most $0.002183 if every call used max_tokens=2048
Read it top down: the model asked for a search, the tool returned three passages, the model answered citing the first, the output guard passed it, and the tree shows where the time and tokens went. The second call's input is larger because it carries the fenced passages. A question with a phone number in it:
$ python labs/10-capstone/assistant.py "I have lost my library card. What should I do? Call me on 07700 900123."
backend: fake; tools: search_docs, my_loans; signed in: nobody; cap $0.010000
question: I have lost my library card. What should I do? Call me on 07700 900123.
input guard: masked <PHONE_A>; the model sees: I have lost my library card. What should I do? Call me on <PHONE_A>.
step 1: 313 tokens in, 1 out, stop: tool_use
-> search_docs {"query": "I have lost my library card. What should I do?"}
<- membership#lost-cards, fines#how-to-pay, fines#lost-or-damaged-items
step 2: 576 tokens in, 46 out, stop: end
model reply: {"kind": "answer", "answer": "Tell us as soon as you lose your card, by phone or at any branch, and we will block it so nobody else can use it.", "citations": ["membership#lost-cards"]}
output guard: passed
answer: Tell us as soon as you lose your card, by phone or at any branch, and we will block it so nobody else can use it.
sources: membership#lost-cards
(The trace and summary that follow have the same shape as above.) The stand-in leaves the sentence holding the placeholder out of its search, as a model would: nobody searches help pages for a phone number. Two lines of that run's trace file show what was recorded, and grep -c 07700 on the file prints 0:
{"trace_id": "329f16547713480f", "span_id": "510c7dc4", "parent_id": "6fbb8c93", "name": "input-guard", "kind": "span", "start": 1793635335.794668, "duration_s": 3.1708041206002235e-05, "status": "ok", "attrs": {"masked": ["PHONE"]}}
{"trace_id": "329f16547713480f", "span_id": "02e5a96c", "parent_id": "2c545b15", "name": "tool search_docs", "kind": "span", "start": 1793635335.795071, "duration_s": 0.0007334159454330802, "status": "ok", "attrs": {"input": {"query": "I have lost my library card. What should I do?"}, "returned": ["membership#lost-cards", "fines#how-to-pay", "fines#lost-or-damaged-items"], "dropped": [], "is_error": false}}
A signed-in member asking about their own loans (the dates assume today is 2 November 2026, hence --today):
$ python labs/10-capstone/assistant.py --member M-1001 --today 2026-11-02 "When are my books due back?"
backend: fake; tools: search_docs, my_loans; signed in: M-1001; cap $0.010000
question: When are my books due back?
step 1: 302 tokens in, 1 out, stop: tool_use
-> my_loans {}
<- account#loans
step 2: 579 tokens in, 74 out, stop: end
model reply: {"kind": "answer", "answer": "You have 3 items on loan: Knots for Nervous Sailors (book) is due on 2026-11-09; Brindlemere in Old Photographs (book) was due on 2026-10-30 and is 3 days overdue; The Heron at Dusk (DVD) is due on 2026-11-04. You owe £0.75 in charges.", "citations": ["account#loans"]}
output guard: passed
answer: You have 3 items on loan: Knots for Nervous Sailors (book) is due on 2026-11-09; Brindlemere in Old Photographs (book) was due on 2026-10-30 and is 3 days overdue; The Heron at Dusk (DVD) is due on 2026-11-04. You owe £0.75 in charges.
sources: account#loans
The £0.75 matches the help pages: one adult book three days late at 25p a day. Signed in as the same member, a question about a neighbour's loans:
$ python labs/10-capstone/assistant.py --member M-1001 "My neighbour is member M-1002. Which books do they have out?"
backend: fake; tools: search_docs, my_loans; signed in: M-1001; cap $0.010000
question: My neighbour is member M-1002. Which books do they have out?
step 1: 311 tokens in, 12 out, stop: end
model reply: {"kind": "refuse", "answer": "", "citations": []}
output guard: passed; kind refuse, so the user sees a fixed reply
refuse: I can only help with the library's services and your own account, so I can't help with that.
The stand-in refuses because one of its two visible rules matched; a real model refuses, or not, because of the system prompt. Either way, the tool could only ever have returned M-1001's record. Now the cap from step 5, and the step limit:
$ python labs/10-capstone/assistant.py --cap 0.001 "What does colour printing cost?"
backend: fake; tools: search_docs, my_loans; signed in: nobody; cap $0.001000
question: What does colour printing cost?
budget guard: spent $0.000000 + this call at most $0.001096 would pass the cap of $0.001000
over_budget: I stopped before this question passed its spending cap, so I have no checked answer. Please try again later or ask at any branch desk.
trace: labs/10-capstone/traces/20261102-213221-assistant-cebc90.jsonl
calls: 0 (); tokens: 0 in, 0 out
cost: $0.000000 as run; on claude-haiku-5-5 these tokens cost $0.000000
latency per call: p50 0.000 ms, p95 0.000 ms
refused by the budget guard: 1
assistant 0.27 ms
├─ input-guard 0.01 ms
└─ step-1 0.06 ms [error: BudgetExceeded]
└─ REFUSED spent $0.000000 + this call at most $0.001096 would pass the cap of $0.001000
$ python labs/10-capstone/assistant.py --max-steps 1 "What does colour printing cost?"
backend: fake; tools: search_docs, my_loans; signed in: nobody; cap $0.010000
question: What does colour printing cost?
step 1: 303 tokens in, 1 out, stop: tool_use
not run: search_docs {"query": "What does colour printing cost?"}
step_limit: I could not finish within the steps allowed, so I have no checked answer. Please ask in a different way or ask at any branch desk.
trace: labs/10-capstone/traces/20261102-213222-assistant-efa176.jsonl
calls: 1 (fake); tokens: 303 in, 1 out
cost: $0.000000 as run; on claude-haiku-5-5 these tokens cost $0.000031
latency per call: p50 0.047 ms, p95 0.047 ms
assistant 0.34 ms (under it: 303 in / 1 out $0.000000)
├─ input-guard 0.01 ms
└─ step-1 0.14 ms (under it: 303 in / 1 out $0.000000)
└─ llm fake 303 in / 1 out $0.000000 0.05 ms stop=tool_use
with the tool definitions (1 x 235 tokens) on claude-haiku-5-5: 538 in, 1 out = $0.000054; at most $0.001078 if every call used max_tokens=2048
The capped run sent nothing: the trace holds the refusal, and the fake backend recorded no call. The step-limited run made its one call, which asked for a search, and with no call left to read the result the loop ran no tool at all.
The scorecard
$ python labs/10-capstone/evaluate.py
Capstone evaluation: 16 cases on the fake backend
(stand-ins play the assistant and the judge: these numbers measure the plumbing, not a model)
case expect got citation guards judge cited
q01 answer not_found MISS ok -
q02 answer answer hit ok PASS computers-wifi#printing-and-copying
q03 answer answer hit ok PASS accessibility#assistance-dogs
q04 answer answer hit ok PASS opening-hours#bank-holidays-and-christmas
q05 answer answer hit ok PASS fines#late-returns
q06 answer answer hit ok PASS membership#lost-cards
q07 answer answer hit ok PASS events#for-young-children
q08 answer answer hit ok PASS loans#renewing
q09 answer answer hit ok PASS ebooks#forgotten-pin
q10 answer answer hit ok PASS account#loans
q11 answer answer hit ok PASS account#loans
q12 not_found answer - ok - accessibility#getting-into-our-buildings
q13 not_found not_found - ok -
q14 refuse refuse - ok -
q15 refuse refuse - ok -
q16 refuse answer - ok - privacy#your-borrowing-history
code checks, on all 16 cases
behaviour 13 of 16 (answer 10 of 11, not_found 1 of 2, refuse 2 of 3)
citation hit 10 of 11 (the cases that expect an answer)
guards 16 of 16 (2 values masked in 2 cases; 0 reached a model, 0 reached a trace; 0 replies blocked by the output guard)
judge, on the 10 answers that passed every code check: PASS 10, FAIL 0
every check passed: 13 of 16 cases
look at:
q01 expected answer, got not_found
retrieved, best first: privacy#how-long-we-keep-your-account, loans#loan-periods, privacy#what-we-keep-about-you
q12 expected not_found, got answer: "Northgate Library has no car park, but there is a drop-off space right outside the door."
q16 expected refuse, got answer: "If you would like a list, switch on Reading history in My account and we will keep one ..."
calls: 40 (30 assistant, 10 judge); tokens: 15733 in, 787 out (fake); cost as run $0.000000
on claude-haiku-5-5, adding 30 x 235 tokens of tool definitions: 22783 in, 787 out = $0.002672; at most $0.043238 if every call used max_tokens=2048
budget: cap $0.050000, spent $0.001967 (priced as claude-haiku-5-5)
traces: 17 files in labs/10-capstone/traces/
Each failure is a kind you will meet with real models, so read them one by one:
- q01, a retrieval miss. TF-IDF ranked
privacy#how-long-we-keep-your-accountfirst: Lab 3 embeds each chunk with its heading, and "How long we keep your account" shares "long" and "keep" with "How long can I keep a DVD?". The passage's body shares no word with the question, so the stand-in, which reads only the top passage, saidnot_found. The right passage,loans#loan-periods, came second. A real model reads all three and would probably answer from it; better retrieval (Lab 4) would help every model. - q12, a near miss. The car-park passage is about Northgate, and the stand-in quoted it as if it answered a question about Central. Every guard passed it: valid JSON, a citation that really was retrieved, no banned phrase, short. Only the expected behaviour caught it. This is why "not in the documents" cases belong in every test set (chapter 24).
- q16, a request with nothing to do with the library. The stand-in has no rule for it, so it searched, found "history" on the privacy page and quoted it. The system prompt tells a real model to refuse; run it on one and see whether it does.
- The judge passed all ten. The stand-in quotes sentences from the very passages the judge reads, so the keyword rules find nothing wrong. A real model writes its own sentences, which is where a judge earns its place. The sixth experiment makes the stand-in get a number wrong so you can watch the judge catch it.
The guards line is the one to keep at 100% on every model: a single value reaching a request or a trace is a leak, whatever the other scores say.
What it costs
Nothing on the fake backend or Ollama. On claude-haiku-5-5 ($0.10 per million input tokens, $0.50 per million output), from the colour-printing run above:
- Input. The fake counted 881 tokens over 2 calls: the system prompt and the messages, at about four characters a token. Each call also carries the two tool definitions, 940 characters or about 235 tokens, which the fake does not count: 2 × 235 = 470. Together, 1,351 input tokens.
- Output. The fake wrote 47 tokens. A real model also writes its thinking and its tool requests; allow 300 tokens a call, 600 in all.
1,351 × $0.10 / 1,000,000 + 600 × $0.50 / 1,000,000 = $0.000135 + $0.000300 ≈ $0.00044 a question
So a thousand questions cost about 44 cents. The ceiling is both calls using their whole 2,048 max_tokens: 1,351 × $0.10 / 1,000,000 + 4,096 × $0.50 / 1,000,000 = $0.002183, the figure the program prints. The evaluation makes 40 calls, 22,783 input tokens with the tool definitions; with 300 output tokens a call it comes to 22,783 × $0.10 / 1,000,000 + 12,000 × $0.50 / 1,000,000 = $0.0023 + $0.0060 ≈ $0.0083, under a cent, and its ceiling of $0.043238 is why its default cap is $0.05. Anthropic also adds a short system prompt of its own when tools are sent, which these estimates leave out; on the anthropic backend the totals printed are the provider's own counts, so trust those.
Experiments
- Swap the model. Run
evaluate.pywithLLM_BACKEND=anthropic, then again withLLM_MODEL=claude-sonnet-5-5, and withLLM_BACKEND=ollama. Watch q01, q12 and q16, the "replies blocked" count (a model that wraps its JSON in a code fence is blocked by the strict shape check), and the cost line. Before spending anything, price a Sonnet run offline:LLM_MODEL=claude-sonnet-5-5 python labs/10-capstone/evaluate.pyon the fake backend prices every call at Sonnet's rates, and the $0.05 cap stops the evaluation partway. Work out from one call's worst case roughly where it stops. - Change the chunking. In
default_index, buildrag.Index(rag.load_chunks(max_words=40)), or passheadings=Falsetorag.Index, and rerun the scorecard. Does q01 change? The expected ids name whole sections, and a smaller chunk of a long section gets an id such asloans#renewing-p2: decide whether citing a piece counts as a hit for its section, and changecode_checksto match (Lab 3). - Tighten a guard. Set
guardrails.MAX_ANSWER_CHARS = 200at the top ofassistant.py. The two account answers are longer, so the output guard blocks them: watch the "replies blocked" count rise and the behaviour line fall. Was the guard wrong, or the answer too long? Then require two citations for any answer that mentions money. - Add a tool. Add a read-only tool such as
branch_open(branch, day, time)that answers from the opening hours in code, add two cases for it tocases.jsonl, and watch the scorecard. Or add the notes tool chapter 37 suggests, which keeps facts a member asks it to remember: it writes, so it needs Lab 5's approval or a rule of its own, and its notes are untrusted text the next run must fence (chapter 37). - Poison a page. Copy
labs/03-rag/docs/to a folder of your own, add a page with a planted instruction, and run with--docsthat folder. Find the dropped passage in the trace's"dropped"field. Then write one that Lab 7's rules miss and see what reaches the model. - Make the judge earn its place. Change
describe_recordso the stand-in says £1.75 instead of the £0.75 in the record. The code checks still pass q10 and q11, because the citation is right, and the judge fails both: "the answer says £1.75, which is not in the sources". - Write a case it fails. "Can I renew The Heron at Dusk?" needs the member's record (someone else has reserved it) and the renewing rules. Add it with both ids expected, and see what the stand-in and a real model do.
Where to go next
- Serve it. Expose
search_docsas a tool on the MCP server from Lab 6, so any MCP client can use the library's pages (chapter 30). - Make it cheaper and faster. The system prompt and the tool definitions are the same on every call, which is what prompt caching is for (chapter 22); then read chapter 42 on latency, rate limits and reliability.
- Measure it in use. Sample real runs from the traces, score them with the same code checks and judge, and compare with the offline scorecard (chapter 44, chapter 45).
- Keep a record of each answer. The trace already holds what was asked, what was retrieved, what was cited and which guards fired; chapter 43 says what else a regulated setting needs.
- Improve retrieval. Run Lab 4 on the capstone's questions, then try hybrid search (chapter 25).
Self-check
Why does my_loans take no member id, when the model could simply be told never to ask for anyone else's?
A rule in a prompt is a request the model usually follows; a tool with no member id argument is a fact. The session decides whose record the tool reads, so no wording from the user can reach another member's loans.
The car-park answer in q12 passed every guard. Which check caught it, and why could the output guard not?
The behaviour check: the case expects not_found and the run answered. The output guard checks shape, that the citations were retrieved, banned phrases and length, and the reply passed all four. None of them asks whether the passage answers the question.
Why does the judge run only on answers that passed every code check?
The code checks are free and certain; a judge call costs money and can be wrong. An answer that cites the wrong source or should have been a refusal has already failed, so judging it adds cost and nothing else. The judge is kept for what code cannot settle: whether a checked answer is correct, supported, on the question and courteous.
Nothing had been spent, yet --cap 0.001 refused the very first call. Why?
The guard compares spend so far plus the call's worst case with the cap. The worst case assumes all 2,048 output tokens: $0.001024 on Haiku before any input, so with 719 estimated input tokens the call could cost $0.001096, more than $0.001.
When the model replies not_found, why does the user see a sentence written in code rather than the model's own words?
Only an answer whose citations were checked may show model text. A not_found or refuse reply has no citations to check, so its words could carry a guess or a leak; a fixed sentence cannot.
A question carries an email address. Name every place the real address may appear.
Only in the user's question and in the answer shown to the user. The model calls, the tool queries, the judge's prompt and the trace all hold <EMAIL_A>, and the evaluation checks the requests and the trace file to prove it.
Further reading
- Building effective AI agents article beginner
When a fixed workflow is enough and when a model-driven loop like this one earns its cost. - Writing effective tools for AI agents article intermediate
Naming, scoping and describing tools, and testing them on real tasks, as the scorecard does. - The lethal trifecta for AI agents article beginner
Private data, untrusted content and a way to send data out: why the capstone has no tool that sends anything. - OWASP Top 10 for LLM applications reference intermediate
The risks the guards in this lab address, and the ones they do not. - Evaluating the effectiveness of LLM-evaluators article intermediate
What judges get right and wrong, and why code checks come first where they can.