Module 4: Parallel fan-out and aggregation
Mini-project: fan-out across Reservo
Description
Seven lessons left the fan-out pattern complete: how to confirm independence (02), the base case with fixed order (03), deterministic aggregation (04), the variant inside a single agent (05), the real-concurrency extension (06), and the saving measured in rounds (07). This mini-project doesn't add any new concept — it gives you three Reservo scenarios you've never seen and asks you to apply the complete pattern: recognize which type of fan-out applies (between agents, inside an agent, or neither), dispatch it with real concurrency when it applies, and cite each one's coordination cost.
The most important scenario in the batch is B — on purpose, it's NOT fan-out between agents. Correctly distinguishing when lessons 03-06's mechanism ISN'T needed is as much a part of this module's criteria as knowing how to build it.
Connection to the module
This mini-project is the synthesis of the seven previous lessons, not a new lesson. From 02 you
use the independence criterion. From 03-04, SubTask and deterministic aggregation. From 05, the
criterion for recognizing when fan-out is internal to an agent, not between agents. From 06,
run_fanout_parallel. From 07, fanout_rounds to cite each scenario's saving. When you're done,
Module 5 picks up this same Reservo and builds the next pattern: handoff, where an agent
already in progress decides, mid-task, to hand control to another agent.
The assignment
Reservo hands you three requests that came in the same week:
Scenario A: "Quote Studio pro 2h and tell me if there's a fee for not showing up."
Scenario B: "Compare Focus, Studio, and Boardroom, all pro, 3h."
Scenario C: "Book Boardroom pro 4h for Marta, tell me the cancellation policy,
and compare Focus, Studio, and Boardroom, all pro, 3h."
Your assignment has three parts:
a) For each scenario, decide which pattern applies: fan-out between agents (lessons 03-06), fan-out inside a single agent (lesson 05), or neither.
b) Dispatch each scenario with the correct mechanism, citing each sub-task's history and answer (or the single agent's, if there's no need to split across several).
c) Cite each scenario's coordination cost — model calls, hops, and sequential vs. parallel
rounds, using lesson 07's fanout_rounds where it applies.
The complete solution (the deliverable)
See the complete solution
import concurrent.futures
from dataclasses import dataclass
import reservo_tools as rt
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_parallel(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})")
POLICY_DOCS = {
"no-show-policy": (
"If a member doesn't show up for a confirmed booking and doesn't "
"cancel at least 2 hours in advance, Reservo charges 50% of the "
"quoted price as a no-show fee."
),
"cancellation-policy": (
"Bookings can be cancelled at no charge up to 2 hours before the "
"booked time slot. Cancellations within those 2 hours incur the "
"no-show fee."
),
}
def search_docs(query):
q = query.lower()
if "cancel" in q:
return f"[cancellation-policy] {POLICY_DOCS['cancellation-policy']}"
if "no" in q and "show" in q:
return f"[no-show-policy] {POLICY_DOCS['no-show-policy']}"
return "No relevant policy was found for that question."
SPECIALISTS = {
"booking_agent": {
"tools": {
"list_rooms": rt.list_rooms, "get_quote": rt.get_quote,
"book_room": rt.book_room, "cancel_booking": rt.cancel_booking,
},
},
"policy_agent": {"tools": {"search_docs": search_docs}},
"pricing_agent": {"tools": {"get_quote": rt.get_quote}},
}
def run_specialist(name, task, model_script):
tools = SPECIALISTS[name]["tools"]
return run_agent_parallel(task, model_script, tools)
def count_model_calls(history):
return sum(1 for m in history if m["role"] == "assistant")
def count_tool_calls(history):
total = 0
for m in history:
if isinstance(m["content"], list):
total += sum(1 for b in m["content"] if b["type"] == "tool_use")
return total
@dataclass
class SubTask:
agent: str
task: str
def run_fanout_parallel(subtasks, model_scripts):
results = {}
with concurrent.futures.ThreadPoolExecutor(max_workers=len(subtasks)) as pool:
future_to_subtask = {
pool.submit(run_specialist, sub.agent, sub.task, model_scripts[sub.agent]): sub
for sub in subtasks
}
for future in concurrent.futures.as_completed(future_to_subtask):
sub = future_to_subtask[future]
final, history = future.result()
results[sub.agent] = {"task": sub.task, "output": final["content"][0]["text"], "history": history}
return results
def fanout_rounds(call_counts_list, surrounding_calls):
sequential = sum(call_counts_list) + surrounding_calls
parallel = max(call_counts_list) + surrounding_calls
return sequential, parallel
def report(label, results, split_calls=1, compose_calls=1):
print(f"=== {label} ===")
for agent in sorted(results):
print(f"[{agent}] task: {results[agent]['task']!r}")
print(f"[{agent}] output: {results[agent]['output']!r}")
specialist_calls = sum(count_model_calls(results[a]["history"]) for a in results)
specialist_tools = sum(count_tool_calls(results[a]["history"]) for a in results)
call_counts = [count_model_calls(results[a]["history"]) for a in results]
total_calls = split_calls + specialist_calls + compose_calls
seq_rounds = split_calls + sum(call_counts) + compose_calls
par_rounds = split_calls + max(call_counts) + compose_calls
print(f"model calls TOTAL: {total_calls} | tool calls: {specialist_tools}")
print(f"sequential rounds: {seq_rounds} | parallel rounds: {par_rounds} | saved: {seq_rounds - par_rounds}")
print()
# --- Scenario A: quote AND no-show policy -- fan-out BETWEEN two agents ---
task_a = "Quote Studio pro 2h and tell me if there's a fee for not showing up."
subtasks_a = [
SubTask(agent="booking_agent", task="Quote Studio pro 2h."),
SubTask(agent="policy_agent", task="Is there a fee for not showing up to my booking?"),
]
model_scripts_a = {
"booking_agent": [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
"input": {"room": "Studio", "tier": "pro", "hours": 2}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Studio pro 2h costs 6400 cents."}]},
],
"policy_agent": [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "search_docs",
"input": {"query": "not showing up to my booking"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": (
"Yes -- if you don't show up and don't cancel at least 2 hours "
"in advance, 50% of the quoted price gets charged."
)}]},
],
}
print("--- compound request A ---")
print(repr(task_a))
results_a = run_fanout_parallel(subtasks_a, model_scripts_a)
report("Scenario A (real fan-out: booking_agent + policy_agent)", results_a)
# --- Scenario B: comparing THREE rooms -- fan-out INSIDE a single agent ---
task_b = "Compare Focus, Studio, and Boardroom, all pro, 3h."
model_script_pricing_b = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
"input": {"room": "Focus", "tier": "pro", "hours": 3}},
{"type": "tool_use", "id": "toolu_02", "name": "get_quote",
"input": {"room": "Studio", "tier": "pro", "hours": 3}},
{"type": "tool_use", "id": "toolu_03", "name": "get_quote",
"input": {"room": "Boardroom", "tier": "pro", "hours": 3}},
]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": (
"Focus pro 3h: 6000 cents. Studio pro 3h: 9600 cents. "
"Boardroom pro 3h: 19200 cents. Focus is the cheapest "
"option of the three."
)}]},
]
print("--- request B (NOT fan-out between agents) ---")
print(repr(task_b))
final_b, hist_b = run_specialist("pricing_agent", task_b, model_script_pricing_b)
print("is run_fanout_sequential/run_fanout_parallel needed? No -- a single agent "
"(pricing_agent) handles everything, with fan-out INSIDE its own turn (lesson 05).")
print("answer:", final_b["content"][0]["text"])
print(f"model calls: {count_model_calls(hist_b)} | tool calls: {count_tool_calls(hist_b)}")
print()
# --- Scenario C: book + policy + compare -- fan-out of THREE agents ---
task_c = ("Book Boardroom pro 4h for Marta, tell me the cancellation policy, "
"and compare Focus, Studio, and Boardroom, all pro, 3h.")
subtasks_c = [
SubTask(agent="booking_agent", task="Book Boardroom pro 4h for Marta."),
SubTask(agent="policy_agent", task="What's the cancellation policy?"),
SubTask(agent="pricing_agent", task="Compare Focus, Studio, and Boardroom, all pro, 3h."),
]
model_scripts_c = {
"booking_agent": [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "book_room",
"input": {"room": "Boardroom", "tier": "pro", "hours": 4, "member": "Marta"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "I booked Boardroom pro 4h for Marta (confirmation #1), 25600 cents."}]},
],
"policy_agent": [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "search_docs",
"input": {"query": "cancellation policy"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": (
"You can cancel at no charge up to 2 hours before the "
"booked time. After that, the no-show fee applies."
)}]},
],
"pricing_agent": model_script_pricing_b,
}
print("--- compound request C (fan-out of THREE agents) ---")
print(repr(task_c))
results_c = run_fanout_parallel(subtasks_c, model_scripts_c)
report("Scenario C (real fan-out: booking_agent + policy_agent + pricing_agent)", results_c)
print("--- confirmation of the booking created in Scenario C ---")
print(rt.BOOKINGS)
What to expect:
--- compound request A ---
"Quote Studio pro 2h and tell me if there's a fee for not showing up."
=== Scenario A (real fan-out: booking_agent + policy_agent) ===
[booking_agent] task: 'Quote Studio pro 2h.'
[booking_agent] output: 'Studio pro 2h costs 6400 cents.'
[policy_agent] task: 'Is there a fee for not showing up to my booking?'
[policy_agent] output: "Yes -- if you don't show up and don't cancel at least 2 hours in advance, 50% of the quoted price gets charged."
model calls TOTAL: 6 | tool calls: 2
sequential rounds: 6 | parallel rounds: 4 | saved: 2
--- request B (NOT fan-out between agents) ---
'Compare Focus, Studio, and Boardroom, all pro, 3h.'
is run_fanout_sequential/run_fanout_parallel needed? No -- a single agent (pricing_agent) handles everything, with fan-out INSIDE its own turn (lesson 05).
answer: Focus pro 3h: 6000 cents. Studio pro 3h: 9600 cents. Boardroom pro 3h: 19200 cents. Focus is the cheapest option of the three.
model calls: 2 | tool calls: 3
--- compound request C (fan-out of THREE agents) ---
'Book Boardroom pro 4h for Marta, tell me the cancellation policy, and compare Focus, Studio, and Boardroom, all pro, 3h.'
=== Scenario C (real fan-out: booking_agent + policy_agent + pricing_agent) ===
[booking_agent] task: 'Book Boardroom pro 4h for Marta.'
[booking_agent] output: 'I booked Boardroom pro 4h for Marta (confirmation #1), 25600 cents.'
[policy_agent] task: "What's the cancellation policy?"
[policy_agent] output: 'You can cancel at no charge up to 2 hours before the booked time. After that, the no-show fee applies.'
[pricing_agent] task: 'Compare Focus, Studio, and Boardroom, all pro, 3h.'
[pricing_agent] output: 'Focus pro 3h: 6000 cents. Studio pro 3h: 9600 cents. Boardroom pro 3h: 19200 cents. Focus is the cheapest option of the three.'
model calls TOTAL: 8 | tool calls: 5
sequential rounds: 8 | parallel rounds: 4 | saved: 4
--- confirmation of the booking created in Scenario C ---
{1: {'booking_id': 1, 'room': 'Boardroom', 'tier': 'pro', 'hours': 4, 'member': 'Marta', 'price_cents': 25600}}
The reasoning per scenario:
Scenario A — fan-out between two agents, no surprises. "Quote Studio pro 2h" and "is there a
fee for not showing up?" are two completely separate questions, to different specialists — the
exact same pattern from lessons 03-06. 6400 = 4000 * 2 * 80 // 100. Cost: 6 calls, 4 rounds in
parallel (saving 2 against the 6 it would cost sequentially).
Scenario B — the batch trap, and the case that's NOT fan-out between agents. Even though
"compare Focus, Studio, and Boardroom" splits independent work (no quote depends on another), all
three live inside the same turn of the same agent (pricing_agent) — exactly lesson 05's
variant, not lessons 03-06's mechanism. Wrapping this in run_fanout_sequential or
run_fanout_parallel wouldn't technically be wrong (lesson 05's Exercise 3 already confirmed the
result is identical with a single sub-task) — but it would be adding unnecessary code for a case
dispatch_parallel, already built in agent-fundamentals, resolves on its own.
Scenario C — real fan-out of three agents, with an actual booking inside. Unlike scenarios A
and B, here booking_agent doesn't just quote — it actually books, with book_room. That
changes nothing about the fan-out mechanism: it's still a sub-task independent of the other two
(neither the cancellation policy nor the price comparison needs Marta's booking to already exist).
The cost scales as lesson 07's formula predicted: with three sub-tasks of 2 calls each, 8 rounds
sequential against 4 in parallel — double the saving of Scenario A, with one more sub-task.
Common mistakes
-
Forcing Scenario B through
run_fanout_sequential/run_fanout_parallel"to be consistent" with A and C. The whole point of this mini-project is precisely recognizing when the mechanism ISN'T needed — wrapping Scenario B anyway doesn't produce an incorrect result, but it adds code with no benefit. -
Confusing Scenario B (fan-out inside an agent) with "there's no parallelism at all." There is —
pricing_agent's three quotes run in the same turn, split bydispatch_parallel. What there isn't is fan-out between agents, which is what this module built from scratch. -
Thinking Scenario C "failed" because it had more calls than A. It didn't fail — it has one more sub-task (three specialists instead of two), so it's expected to cost more calls in total. What matters isn't the absolute total, it's the sequential-vs-parallel comparison within the SAME scenario.
-
Running the three scenarios in the same process without resetting state between runs. If you run this mini-project after another example from the guide in the same interpreter, Scenario C's
booking_idmight not come out as1— every scenario in this guide assumes its own fresh, disposable Reservo instance. -
Forgetting
sorted(results)when reporting Scenario C. With three agents in real fan-out, the temptation to print in whatever orderrun_fanout_parallelreturned them is greater than with two — lesson 06's discipline applies exactly the same way, no matter how many sub-tasks there are.
Exercises
Exercise 1: Confirm your own run (Easy)
Run the three scenarios from the assignment yourself and confirm, line by line, that your output
matches the complete solution. For Scenario B, write one additional sentence explaining why you
didn't use run_fanout_parallel, even though the request also splits independent work.
See solution
There's no single "code solution" for the first part — it's a check: if your output matches the complete solution's, your fresh Reservo instance started clean.
About Scenario B: the three quotes' independence is real, but it lives inside a single agent's
(pricing_agent's) turn, not between different agents with separate histories —
dispatch_parallel, already built in agent-fundamentals, resolves that parallelism without
needing any piece from this module.
Exercise 2: A fourth scenario, D (Medium)
Design a fourth Reservo scenario: "Quote Boardroom pro 3h and tell me if I can cancel at no charge with 3 hours' notice." Decide which pattern applies and run it with the correct mechanism.
See solution
task_ex2 = "Quote Boardroom pro 3h and tell me if I can cancel at no charge with 3 hours' notice."
subtasks_ex2 = [
SubTask(agent="booking_agent", task="Quote Boardroom pro 3h."),
SubTask(agent="policy_agent", task="Can I cancel at no charge with 3 hours' notice?"),
]
model_scripts_ex2 = {
"booking_agent": [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
"input": {"room": "Boardroom", "tier": "pro", "hours": 3}}]},
{"stop_reason": "end_turn", "content": [{"type": "text", "text": "Boardroom pro 3h costs 19200 cents."}]},
],
"policy_agent": [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "search_docs",
"input": {"query": "cancellation with notice"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Yes -- 3 hours' notice is more than the 2-hour minimum, so no fee applies."}]},
],
}
print("request:", repr(task_ex2))
results_ex2 = run_fanout_parallel(subtasks_ex2, model_scripts_ex2)
for agent in sorted(results_ex2):
print(f"[{agent}] {results_ex2[agent]['output']}")
print("Boardroom pro 3h by hand:", 8000 * 3 * 80 // 100)
Expected output:
request: "Quote Boardroom pro 3h and tell me if I can cancel at no charge with 3 hours' notice."
[booking_agent] Boardroom pro 3h costs 19200 cents.
[policy_agent] Yes -- 3 hours' notice is more than the 2-hour minimum, so no fee applies.
Boardroom pro 3h by hand: 19200
Explanation: two independent questions, to two different specialists — the same pattern as
Scenario A, with new data. 19200 = 8000 * 3 * 80 // 100.
Exercise 3: Verify Scenario C's rounds saving with the formula (Hard)
Using lesson 07's fanout_rounds, predict Scenario C's sequential and parallel rounds (three
sub-tasks, each with 2 internal calls, surrounding_calls=2) and confirm it matches what the
complete solution measured.
See solution
call_counts_c = [2, 2, 2] # booking_agent, policy_agent, pricing_agent -- 2 calls each
seq_c, par_c = fanout_rounds(call_counts_c, surrounding_calls=2)
print(f"predicted with the formula: sequential={seq_c}, parallel={par_c}, saved={seq_c - par_c}")
print("measured in the assignment's Scenario C: sequential=8, parallel=4, saved=4")
print("do they match?", (seq_c, par_c) == (8, 4))
Expected output:
predicted with the formula: sequential=8, parallel=4, saved=4
measured in the assignment's Scenario C: sequential=8, parallel=4, saved=4
do they match? True
Explanation: lesson 07's formula predicts exactly what report() measured on the real
Scenario C — confirming the cost model (sum() for sequential, max() for parallel, plus the
fixed surrounding_calls) generalizes with no adjustments to the three-agent case, not just the
two-agent one lessons 03-07 used.
Summary and next step
- The mini-project added no new concept: it applied the seven previous lessons — independence criterion, base case, deterministic aggregation, the variant inside an agent, real concurrency, and the saving measured in rounds — to three new Reservo scenarios.
- Two of the three scenarios (A, C) were real fan-out between agents, with coordination cost measured and cited; Scenario B confirmed, on a new case, that "independent work" doesn't always mean "fan-out between agents" — sometimes it lives inside a single agent's turn.
- Scenario C showed the pattern scales without changes to three agents, with a real booking
(
book_room) as one of the sub-tasks — fan-out doesn't distinguish between read-only sub-tasks and sub-tasks with real effects, as long as they're genuinely independent. - With this module complete, you have three of the guide's five patterns built: supervisor (M2, decides who), pipeline (M3, chains without deciding), fan-out (this module, splits and aggregates).
This closes Module 4. You built the complete parallel fan-out pattern: the independence criterion, the base case with fixed order, deterministic aggregation, the distinction from an agent's internal fan-out, the real-concurrency extension, and the rounds saving measurement. In Module 5 we build the fourth pattern: handoff and delegation — when an agent that's ALREADY working realizes, mid-task, that it needs another specialist, and hands off control directly, without going back to an external supervisor.
Additional resources
- Anthropic — Building effective agents — The complete "parallelization" pattern: sectioning (splitting sub-tasks) and aggregator (combining them), the axis of this entire mini-project.
- Anthropic — Multi-agent research system — A real case where recognizing which work is genuinely parallelizable — and which isn't — determined the final system design, same as this assignment's Scenario B.
- Python —
concurrent.futures— The module behindrun_fanout_parallel, running unchanged over two and over three sub-tasks in this mini-project. - Python — Dictionaries and functions — The structure behind
resultsandSPECIALISTS, the foundation of this module's entire fan-out.