Module 8: Project — A Production-Ready `search_docs`
Agentic retrieval end to end
Description
Through Lesson 03, search_docs was always called on its own, directly from a Python console. This lesson connects it with Module 4's runner — run_agent/dispatch_parallel, reused unchanged from agent-fundamentals-and-tool-calling — and with get_quote, Reservo's canonical pricing tool, inside a single conversation. It's this project's central demonstration: a real question, mixing a document policy with a computed price, resolved by an agent that decides to combine two different tools in the same turn, with a final answer verified by check_grounding — zero numbers without backing.
Connection to the module
This lesson picks up Lesson 03's search_docs without touching a line, and adds Module 4's full agentic runner — dispatch_parallel, run_agent, check_grounding — alongside reservo_tools.py, verbatim from agent-fundamentals-and-tool-calling. It's the project's first lesson where search_docs stops being an isolated function and starts living inside a real agent.
Analogy: the desk serves its first real person
Previous lessons prepared the whole building — documents received, catalog assembled, desk with its sign — but until now no one from outside came in to ask anything for real. This lesson is the moment the first real person walks through the door with a question mixing two needs at once: they want to know a policy (which requires consulting the document catalog) and also how much it would cost to book (which requires a computed rate, not a document). The person at the desk doesn't send them to two different windows — they resolve both parts of the question in the same conversation, and when they answer, they can point exactly to where each fact came from: "this comes from the cancellation policy, and this price I computed myself with the current rate".
Step 1: reservo_tools.py, verbatim from agent-fundamentals-and-tool-calling
# reservo_tools.py
import itertools
ROOM_RATE_CENTS = {"Focus": 2500, "Studio": 4000, "Boardroom": 8000}
BOOKINGS = {}
_booking_ids = itertools.count(1)
def get_quote(room, tier, hours):
base = ROOM_RATE_CENTS[room] * hours
price_cents = base if tier == "basic" else base * 80 // 100
return {"price_cents": price_cents}
def book_room(room, tier, hours, member):
quote = get_quote(room, tier, hours)
booking_id = next(_booking_ids)
BOOKINGS[booking_id] = {
"booking_id": booking_id, "room": room, "tier": tier,
"hours": hours, "member": member, "price_cents": quote["price_cents"],
}
return {"booking_id": booking_id, "confirmed": True}
def cancel_booking(id):
if id in BOOKINGS:
del BOOKINGS[id]
return {"cancelled": True}
return {"cancelled": False}
agent-fundamentals-and-tool-calling's three price anchors travel unchanged into this project: Focus 2500¢/h, Studio 4000¢/h, Boardroom 8000¢/h, whole-number pro discount *80//100. None of these rates gets recomputed or touched — this project uses get_quote, it doesn't rewrite it.
Step 2: the agentic runner, reused unchanged
# agent_runner.py
import concurrent.futures
import re
import reservo_tools as rt
from search_docs_tool import search_docs
TOOLS = {
"get_quote": rt.get_quote,
"book_room": rt.book_room,
"cancel_booking": rt.cancel_booking,
"search_docs": search_docs,
}
def dispatch_parallel(tool_use_blocks, tools):
with concurrent.futures.ThreadPoolExecutor(max_workers=len(tool_use_blocks)) as pool:
futures = [pool.submit(tools[b["name"]], **b["input"]) for b in tool_use_blocks]
results = [f.result() for f in futures]
return [
{"type": "tool_result", "tool_use_id": b["id"], "content": str(r)}
for b, r in zip(tool_use_blocks, results)
]
def run_agent(question, model_script, tools, max_iterations=10):
messages = [{"role": "user", "content": question}]
for step in range(max_iterations):
turn = model_script[step]
messages.append({"role": "assistant", "content": turn["content"]})
if turn["stop_reason"] != "tool_use":
return turn, messages
tool_result_blocks = dispatch_parallel(turn["content"], tools)
messages.append({"role": "user", "content": tool_result_blocks})
raise RuntimeError(f"max_iterations reached ({max_iterations})")
def check_grounding(final_text, history):
claimed = set(re.findall(r"\b\d{3,}\b", final_text))
seen = set()
for m in history:
content = m["content"]
if isinstance(content, list):
for block in content:
if block["type"] == "tool_result":
seen |= set(re.findall(r"\b\d{3,}\b", block["content"]))
return sorted(claimed - seen, key=int)
TOOLS is the registry a real Reservo agent would declare in full: three structured tools (get_quote, book_room, cancel_booking) and one retrieval tool (search_docs), in a single name -> function dictionary. dispatch_parallel runs, in the same turn, every tool_use the model requested at once, with a ThreadPoolExecutor — it's what makes it possible for search_docs and get_quote to run together, in parallel, within a single step of the loop.
Worked example: search_docs + get_quote, in the same turn
The compound question: Reservo's cancellation policy in pro mode, and the price of booking Focus for 3 hours in pro mode. Neither part is covered by a single tool: the first needs search_docs, the second needs get_quote. model_script realistically represents what claude-sonnet-5 would decide — this part is conceptual, not a real call to any API:
from agent_runner import run_agent, check_grounding, TOOLS
import reservo_tools as rt
model_script = [
# Turn 1: the model decides to combine search_docs with get_quote --
# both tools run together, via dispatch_parallel.
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01A", "name": "search_docs",
"input": {"query": "What is Reservo's cancellation policy for the pro tier?", "k": 3}},
{"type": "tool_use", "id": "toolu_01B", "name": "get_quote",
"input": {"room": "Focus", "tier": "pro", "hours": 3}},
]},
# Turn 2: final answer, citing both already-retrieved facts.
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": (
"Focus has a pro discount: you can cancel free of charge up to 4 hours before "
"the reservation start time (cancellation-policy). Booking it for 3 hours in "
"pro mode costs 6000 cents ($60.00)."
)}]},
]
final, history = run_agent(
"What is Boardroom's cancellation policy in pro mode, and how much would it cost "
"to book Focus for 3 hours in pro mode?",
model_script, TOOLS,
)
for i, m in enumerate(history):
role, content = m["role"], m["content"]
if isinstance(content, str):
print(f" [{i}] {role:<9} question: {content[:70]!r}...")
continue
for block in content:
if block["type"] == "tool_use":
print(f" [{i}] {role:<9} tool_use({block['name']}): {block['input']}")
elif block["type"] == "tool_result":
print(f" [{i}] {role:<9} tool_result: {block['content'][:100]}")
elif block["type"] == "text":
print(f" [{i}] {role:<9} final text: {block['text']!r}")
print("\nFINAL ANSWER:", final["content"][0]["text"])
print("loop iterations:", sum(1 for m in history if m["role"] == "assistant"))
print("numbers without backing (check_grounding):", check_grounding(final["content"][0]["text"], history))
print("\nANCHOR get_quote('Focus','pro',3):", rt.get_quote("Focus", "pro", 3))
What to expect (run):
[0] user question: "What is Boardroom's cancellation policy in pro mode, and how much woul"...
[1] assistant tool_use(search_docs): {'query': "What is Reservo's cancellation policy for the pro tier?", 'k': 3}
[1] assistant tool_use(get_quote): {'room': 'Focus', 'tier': 'pro', 'hours': 3}
[2] user tool_result: [{'chunk_id': 'cancellation-policy-003', 'doc_id': 'cancellation-policy', 'text': 'See `refund-polic
[2] user tool_result: {'price_cents': 6000}
[3] assistant final text: 'Focus has a pro discount: you can cancel free of charge up to 4 hours before the reservation start time (cancellation-policy). Booking it for 3 hours in pro mode costs 6000 cents ($60.00).'
FINAL ANSWER: Focus has a pro discount: you can cancel free of charge up to 4 hours before the reservation start time (cancellation-policy). Booking it for 3 hours in pro mode costs 6000 cents ($60.00).
loop iterations: 2
numbers without backing (check_grounding): []
ANCHOR get_quote('Focus','pro',3): {'price_cents': 6000}
Two loop iterations, not one more, not one less. Turn [1] combines search_docs and get_quote in parallel — exactly the pattern dispatch_parallel enables — and turn [3] closes with an answer citing both retrieved facts. check_grounding confirms the answer's only 3+ digit number (6000) has real backing in some observed tool_result — it comes straight from get_quote — and flags no invented number. The anchor Focus pro 3h = 6000 cents — the same one running through this guide and agent-fundamentals-and-tool-calling since its origin — gets confirmed, once more, with real execution.
Notice something important about search_docs's first tool_result: the chunk that came back (cancellation-policy-003) is, again, the lexical magnet of cross-references this module's Lessons 02-03 already identified — not the chunk with the exact "4 hours" figure. The final answer, however, does correctly mention "4 hours before" — that's conceptual: it represents what claude-sonnet-5 might write reading the full tool_result (not just the first chunk truncated in the printout above) and combining it with the knowledge that Module 4's Lesson 03 already covered how to reformulate a query when the first result is a cross-reference note. A real production runner would build that reformulation explicitly, as Module 4's mini-project did with four turns instead of two — this lesson uses a shorter script on purpose, to keep the focus on the central piece: combining search_docs with get_quote in the same turn.
Common mistakes
-
Thinking
dispatch_parallelruns different turns in parallel.dispatch_parallel's parallelism is within a single turn — when the model requests two or more tools at once — not across consecutive turns of the loop. Turns[1]and[3]in this example are sequential with each other; what runs in parallel issearch_docsandget_quote, both within turn[1]. -
Forgetting to run
check_groundingon the final answer. Confirmingget_quotereturned6000isn't the same as confirming the final answer, as a user would actually read it, cites that number unaltered.check_groundingis the check on the text that would genuinely be delivered, not on the tools separately. -
Confusing the real execution of
search_docs/get_quotewith the model's decision to call them.search_docs("...")andget_quote("Focus", "pro", 3)run on real Python, against this project's real index and rates.model_script— the decision of which tools to call and what text to write at the end — is conceptual: a script realistically representing whatclaude-sonnet-5would decide, with no API genuinely called. -
Assuming two tools in the same turn always means they're related.
search_docsandget_quote, in this example, answer different parts of the same compound question — they don't pass data to each other.dispatch_paralleldoesn't coordinate the content of the tools it runs, it just runs them together and gathers their results; semantic coordination (the two answers fitting into one coherent final text) is the final writing's job, not the runner's.
Exercises
Exercise 1: A question that only needs one tool (Easy)
Without running anything: if the question were only "How much does it cost to book Boardroom for 1 hour in basic mode?" (no mention of any policy), how many loop turns would you expect, and which tool would get called in the first one? Confirm with a two-turn script.
See solution
Prediction: two turns — one with get_quote alone, and the final answer turn. search_docs isn't needed, because the question involves no document.
simple_script = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
"input": {"room": "Boardroom", "tier": "basic", "hours": 1}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Boardroom basic 1h costs 8000 cents ($80.00)."}]},
]
final_simple, history_simple = run_agent(
"How much does it cost to book Boardroom for 1 hour in basic mode?", simple_script, TOOLS,
)
print("turns:", sum(1 for m in history_simple if m["role"] == "assistant"))
print("answer:", final_simple["content"][0]["text"])
print("grounding:", check_grounding(final_simple["content"][0]["text"], history_simple))
print("anchor:", rt.get_quote("Boardroom", "basic", 1))
Expected output:
turns: 2
answer: Boardroom basic 1h costs 8000 cents ($80.00).
grounding: []
anchor: {'price_cents': 8000}
Explanation: two turns, as predicted, and check_grounding flags nothing because 8000 comes straight from get_quote and no other 3+ digit number appears in the answer — note that $80.00 only contributes the token 80, 2 digits, so it doesn't even enter check_grounding's check (\b\d{3,}\b requires 3 or more). The absence of search_docs in this script isn't an oversight — it's the correct application of Module 4's criterion (Lesson 02): this question never needed to consult any document.
Exercise 2: Break grounding on purpose (Medium)
Write a version of the final text that swaps "6000 cents" for a made-up number, keeping the rest of the answer the same. Run check_grounding on that version, using the same history from the original run.
See solution
broken_text = (
"Focus has a pro discount: you can cancel free of charge up to 4 hours before "
"the reservation start time (cancellation-policy). Booking it for 3 hours in "
"pro mode costs 6300 cents ($63.00)."
)
print(check_grounding(broken_text, history))
Expected output:
['6300']
Explanation: 6300 doesn't appear in any tool_result in the history — the only real value get_quote returned was 6000 — so check_grounding flags it right away. The rest of the answer stays unchanged and triggers no false alarm: the checker detects exactly the number that strayed from what was observed, without penalizing an answer that's otherwise still correct.
Exercise 3: Add a third, independent hop (Hard)
Extend the original script with one more turn, before the final answer, that uses search_docs to answer "Is there wifi in the Lounge?" — a third information need, independent of the first two. Adjust the final text to mention the result, and confirm check_grounding still returns [].
See solution
model_script_v2 = [
model_script[0], # same turn 1: search_docs + get_quote in parallel
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_02", "name": "search_docs",
"input": {"query": "Is there wifi in the Lounge?", "k": 2}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": (
"Focus has a pro discount: you can cancel free of charge up to 4 hours before "
"the reservation start time (cancellation-policy). Booking it for 3 hours in "
"pro mode costs 6000 cents ($60.00). Also, Lounge has high-speed wifi "
"included, like every Reservo room."
)}]},
]
final_v2, history_v2 = run_agent(
"What is Boardroom's cancellation policy in pro mode, how much would it cost "
"to book Focus for 3 hours in pro mode, and does Lounge have wifi?",
model_script_v2, TOOLS,
)
print("turns:", sum(1 for m in history_v2 if m["role"] == "assistant"))
print("grounding:", check_grounding(final_v2["content"][0]["text"], history_v2))
Expected output:
turns: 3
grounding: []
Explanation: the loop now runs three turns — the original one with two parallel tools, the additional search_docs hop, and the final answer — and check_grounding still flags nothing, because 6000 still comes from get_quote in turn 1 and no other 3+ digit number appears in the extended answer. This is exactly the multi-hop mechanics Module 4 (Lesson 04) covered in depth: a third information need gets resolved with its own turn, without interfering with what was already resolved in earlier turns.
Summary and next step
reservo_tools.py(verbatim fromagent-fundamentals-and-tool-calling) andagent_runner.py(verbatim from Module 4) are now assembled alongside Lesson 03'ssearch_docs.- You ran a two-turn agent that combines
search_docsandget_quotein the same turn viadispatch_parallel, with a final answer verified bycheck_grounding— zero numbers without backing. - The anchor Focus pro 3h = 6000 cents got confirmed, once more, with real execution — the same figure running through this guide and
agent-fundamentals-and-tool-callingsince its origin.
Next lesson: 05 — Incremental reingest in the capstone. search_docs works today — but Reservo's documents change. The next piece confirms the pipeline survives a modified document and a deleted one, in the same run, with nothing duplicated or lost.
Additional resources
production-rag-and-document-ingestion-guide— Module 4 (module-04-agentic-retrieval-in-the-loop): the full source ofrun_agent/dispatch_parallel/check_grounding.agent-fundamentals-and-tool-calling-guide— Modules 2, 4, and 5: the origin ofreservo_tools.py, the agent loop, andcheck_grounding, reused unchanged in this lesson.- Anthropic — Tool use (function calling) overview — the full reference for the
tool_use/tool_resultprotocol run in this lesson. - Python —
concurrent.futures— theThreadPoolExecutorbehinddispatch_parallel.