Module 4: Agentic Retrieval in the Loop
Combining `search_docs` with Reservo's Tools
Description
Lesson 02's Question 4 was flagged as the fourth route: a question that needs search_docs and get_quote at the same time, because it mixes a policy fact with a price calculation, and neither part depends on the other. This lesson runs that route end to end, with the protocol's exact shape: both tools requested in the same model turn, dispatched in parallel, with their two results returned together in a single response turn — exactly the pattern you already know from agent-fundamentals-and-tool-calling Module 5, Lesson 05, now with a retrieval tool in one of the two spots instead of two calculation tools.
You're going to run the complete turn, with the runner you already built in that guide without changing a single line, and confirm the anchor that keeps coming back across this module's lessons: Focus pro 3 hours = 6000 cents.
Connection to the module
This lesson solves, with real code, exactly the question Lesson 02 identified but didn't run, and that Lesson 04 touched from the angle of "two independent searches" — here, instead, one of the two searches is a structured tool. Lesson 06 picks up this lesson's result to verify the final answer correctly cites both facts.
Analogy: two orders on the same ticket, one to the archive and one to the calculator
Picking back up the researcher and the waiter from earlier guides: when a client asks "bring me Focus's cancellation policy, and while you're at it tell me how much it would cost to book it for three hours in pro," they don't make two separate trips — first to the archive, then to the calculator, waiting to finish the first before starting the second. They put both requests on the same ticket: one to the archivist, one to whoever runs the calculator, and both work simultaneously because neither needs the other's result to do its part. When both finish, the researcher combines the two answers into a single report. That's exactly what a model turn does with two tool_use blocks — one for search_docs, one for get_quote — and it's exactly the shape you already verified against Claude's documentation in agent-fundamentals-and-tool-calling.
The exact shape, reused unchanged
From agent-fundamentals-and-tool-calling Module 5, Lesson 05 — verified against Claude's official tool-use documentation, without repeating that verification here:
Parallel tool use (default on): one assistant message may contain multiple
tool_useblocks. Execute them concurrently, then return alltool_resultblocks in a single user message.
Applied to search_docs + get_quote in the same turn:
# Model turn (concept): ONE content block, TWO tool_use blocks inside,
# one retrieval and one calculation.
{
"stop_reason": "tool_use",
"content": [
{"type": "tool_use", "id": "toolu_01A", "name": "search_docs",
"input": {"query": "How many hours in advance is a pro tier cancellation free of charge?", "k": 3}},
{"type": "tool_use", "id": "toolu_01B", "name": "get_quote",
"input": {"room": "Focus", "tier": "pro", "hours": 3}},
],
}
# Response turn (runner): ONE user turn, with BOTH tool_result blocks inside.
{
"role": "user",
"content": [
{"type": "tool_result", "tool_use_id": "toolu_01A", "content": "..."},
{"type": "tool_result", "tool_use_id": "toolu_01B", "content": "..."},
],
}
The hard rule stays the same, no matter what kind of tool each block is: both tool_use blocks live in the same content of the same assistant turn, and both tool_result blocks travel together in a single following user turn. The protocol doesn't distinguish between a "calculation tool" and a "retrieval tool" — from the message shape's point of view, search_docs and get_quote are two tool_use blocks identical in every way except their name and input.
Worked example: agent-fundamentals-and-tool-calling's runner, unchanged
This module's tool registry, with the four real functions:
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,
}
And agent-fundamentals-and-tool-calling Module 5, Lesson 05's exact runner — dispatch_parallel with ThreadPoolExecutor, run_agent_parallel with the same bounded for as always — imported as-is, with no adaptation at all for it to accept search_docs:
import concurrent.futures
def dispatch_parallel(tool_use_blocks, tools):
"""Runs several tool_use blocks IN PARALLEL and builds ONE tool_result
for each one, in the same order they arrived. Identical to M5 L05's --
it doesn't distinguish between calculation and retrieval 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})")
Now the script, with the combined turn:
model_script = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01A", "name": "search_docs",
"input": {"query": "How many hours in advance is a pro tier cancellation free of charge?", "k": 3}},
{"type": "tool_use", "id": "toolu_01B", "name": "get_quote",
"input": {"room": "Focus", "tier": "pro", "hours": 3}},
]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": (
"You can cancel Focus in pro mode free of charge up to 4 hours "
"before the booking start time. Booking it for 3 hours in pro "
"mode costs 6000 cents ($60.00)."
)}]},
]
final, history = run_agent(
"What's the pro cancellation window, and how much does it cost to book Focus for three hours in pro mode?",
model_script, TOOLS,
)
print("--- complete history ---")
for i, m in enumerate(history):
role, content = m["role"], m["content"]
if isinstance(content, str):
print(f" [{i}] {role:<9} question: {content[:60]!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'][:90]}")
elif block["type"] == "text":
print(f" [{i}] {role:<9} final text: {block['text']!r}")
print("\nFINAL ANSWER:", final["content"][0]["text"])
print("tool_use blocks in turn 1:", len(model_script[0]["content"]))
print("tool_result blocks in history turn 2:", len(history[2]["content"]))
What to expect:
--- complete history ---
[0] user question: "What's the pro cancellation window, and how much does it cos"...
[1] assistant tool_use(search_docs): {'query': 'How many hours in advance is a pro tier cancellation free of charge?', 'k': 3}
[1] assistant tool_use(get_quote): {'room': 'Focus', 'tier': 'pro', 'hours': 3}
[2] user tool_result: [{'chunk_id': 'cancellation-policy-001', 'doc_id': 'cancellation-policy', 'text': 'Pro mem
[2] user tool_result: {'price_cents': 6000}
[3] assistant final text: 'You can cancel Focus in pro mode free of charge up to 4 hours before the booking start time. Booking it for 3 hours in pro mode costs 6000 cents ($60.00).'
FINAL ANSWER: You can cancel Focus in pro mode free of charge up to 4 hours before the booking start time. Booking it for 3 hours in pro mode costs 6000 cents ($60.00).
tool_use blocks in turn 1: 2
tool_result blocks in history turn 2: 2
Four turns total, not six — exactly like agent-fundamentals-and-tool-calling Module 5, Lesson 05: the two tool_use blocks share turn [1], the two tool_result blocks share turn [2]. search_docs returned, in first place, cancellation-policy-001 — the real chunk, not the pointer note, because this lesson reuses Lesson 03's already-reformulated query — and get_quote returned {'price_cents': 6000}, the Focus pro 3h = 6000 anchor you already know from agent-fundamentals-and-tool-calling. No change to dispatch_parallel or run_agent was needed for this to work: the for loop that walks turn["content"] never knew, or cared, whether a block was requesting a retrieval tool or a calculation one.
Why the order of the two requests doesn't matter here
It's worth being precise about why this case is different from the "chaining" you saw in agent-fundamentals-and-tool-calling Module 4 (for example, list_rooms before get_quote, because the second one needs to know which room the first one picked). Here, search_docs's answer about the cancellation window doesn't depend at all on get_quote's result, and vice versa — Focus pro 3h's price is 2500 * 3 * 80 // 100 = 6000 regardless of what any policy says, and the pro cancellation window is "4 hours" regardless of how much the room costs. That independence is precisely what enables the parallelism: if one tool needed the other's result, they'd have to go in separate turns, one after the other, like sequential chaining.
Common mistakes
-
Requesting
search_docsandget_quotein two separate turns when they're independent. The final result would functionally be the same, but as you already saw inagent-fundamentals-and-tool-callingModule 5, unnecessarily splitting independent requests into separate turns silently trains the model to stop using parallelism when it actually matters. -
Splitting the two
tool_resultblocks across separateuserturns. The protocol's most serious mistake, already established: thetool_resultblocks for a single turn with severaltool_useblocks all go in one single followinguserturn, never split — regardless of whether one is from a retrieval tool and the other from a calculation tool. -
Thinking you need a different runner because one of the tools "searches" instead of "calculates." As you saw in the worked example,
dispatch_parallelandrun_agentneeded no changes at all. TheTOOLSregistry is a simplename -> functiondict; as long as the function accepts**block["input"]and returns something convertible to text, the runner doesn't care about its nature. -
Using the original "What is Reservo's cancellation policy?" query instead of the reformulated one. If this combined turn used the general query instead of Lesson 03's specific one,
search_docswould bring back the pointer note again, and the final answer wouldn't have the real "4 hours" fact to cite — a problem Lesson 06 (grounding) would catch, but one better avoided at the query level itself.
Exercises
Exercise 1: Count the blocks without running it (Easy)
Looking at the worked example's model turn: (a) how many tool_use blocks are there? (b) how many user turns should their tool_result blocks travel in? (c) does the order in which search_docs and get_quote run affect the final result?
See solution
(a) Two — one search_docs and one get_quote, in the same content.
(b) Just one — both tool_result blocks travel together in a single user turn, regardless of coming from tools of a different nature.
(c) No. Neither tool depends on the other's result: search_docs looks up the cancellation window without needing the price, and get_quote computes the price without needing the policy. That's why they can run in parallel, in any order, with the same final result.
Exercise 2: Combine search_docs with book_room (Medium)
Assemble a one-turn script that combines search_docs("Is there wifi in the Lounge?", k=2) with book_room(room="Studio", tier="basic", hours=2, member="Diego") in the same content. Run it with run_agent and confirm both tool_result blocks.
See solution
model_script = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_X1", "name": "search_docs",
"input": {"query": "Is there wifi in the Lounge?", "k": 2}},
{"type": "tool_use", "id": "toolu_X2", "name": "book_room",
"input": {"room": "Studio", "tier": "basic", "hours": 2, "member": "Diego"}},
]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Yes, there's wifi in the common area near the Lounge. Your Studio booking is confirmed."}]},
]
final, history = run_agent(
"Is there wifi near the Lounge? And while you're at it, book me Studio basic for 2 hours, I'm Diego.",
model_script, TOOLS,
)
for block in history[2]["content"]:
print(block["tool_use_id"], "->", block["content"][:80])
Expected output:
toolu_X1 -> [{'chunk_id': 'wifi-and-equipment-faq-002', 'doc_id': 'wifi-and-equipment-faq', 'text': 'Yes.
toolu_X2 -> {'booking_id': 1, 'confirmed': True}
Explanation: search_docs finds wifi-and-equipment-faq-002 ("Yes. Wifi is also available in the common area outside the rooms, including near the Lounge..."), and book_room creates a real booking with booking_id=1 — the same mechanics agent-fundamentals-and-tool-calling Module 2 already established, now living alongside a search in the same turn. This is a case where book_room has real effects (it creates a booking), unlike the worked example's read-only get_quote — the parallelism doesn't distinguish between read-only tools and tools with effects, it only requires the requests to be independent of each other.
Exercise 3: Design a case that should NOT go in parallel (Hard)
Write, in prose, a user question that combines a need for search_docs with one for get_quote, but where search_docs's result is needed to decide get_quote's arguments (that is, a chaining case, not a parallel one). Explain why that case couldn't be solved with a single turn of two tool_use blocks like the worked example's.
See solution
Candidate question: "Which room is the cheapest according to its manual, and how much would it cost to book it for two hours in basic mode?"
Why it can't be parallel: to call get_quote, the model needs a concrete value for room (Focus, Studio, or Boardroom, per the input_schema's enum) — but "the cheapest room" isn't a room name, it's something that first has to be resolved by consulting the manuals or the rates. If the model tried to request search_docs and get_quote in the same turn like in the worked example, it would have to invent a room value for get_quote without yet knowing which room is correct — exactly the kind of invention Lesson 06 (grounding) is designed to catch. The correct shape is sequential: first search_docs (or list_rooms, if it were available) to identify the cheapest room, wait for that result, and only in the following turn request get_quote with the real, now-resolved name — the same chaining pattern from agent-fundamentals-and-tool-calling Module 4, Lesson 05, not this lesson's parallelism.
Summary and next step
search_docsandget_quotecombine in the same turn exactly with the parallelism shape already verified inagent-fundamentals-and-tool-callingModule 5: oneassistantturn with twotool_useblocks, oneuserturn with bothtool_resultblocks together.- The runner (
dispatch_parallel,run_agent) needed no changes at all — it never distinguished between calculation and retrieval tools, it just walked the turn's blocks. - We ran the combined turn end to end:
search_docsbrought back the real pro cancellation window (4 hours, thanks to Lesson 03's already-reformulated query), andget_quoteconfirmed the Focus pro 3h = 6000 cents anchor. - Parallelism only applies when the two requests are independent of each other — when one needs the other's result (Exercise 3), the correct pattern is
agent-fundamentals-and-tool-callingModule 4's sequential chaining, not this lesson's.
Next lesson: 06 — Grounding the answer in chunks. With the policy and the price already retrieved, how do we confirm the agent's final answer really cites those two facts, and not a rounded-off or invented version?
Additional resources
- Anthropic — Tool use (function calling) overview — The official reference on parallel tool calls, the same one you already verified in
agent-fundamentals-and-tool-calling. agent-fundamentals-and-tool-calling-guide, Module 5, Lesson 05 (Parallel tool calls) — the exact source ofdispatch_parallel/run_agent, reused unchanged in this lesson.agent-fundamentals-and-tool-calling-guide, Module 4, Lesson 05 (Chaining tools across several steps) — the sequential pattern that applies when one tool depends on another's result, contrasted in Exercise 3.- Python —
concurrent.futures—ThreadPoolExecutor, the machinery behinddispatch_parallel.