Module 5: Handoff and Delegation
Guarding Against Endless Handoff Chains
Description
Lesson 05's orchestrator, run_with_handoff, assumes the sender hands off the turn only once
and that the receiver always exists. Neither is guaranteed: a badly designed script could make
policy_agent, once it gets control, try to hand it back to booking_agent —a ping-pong—, or a
handoff could point to a specialist name that isn't even in SPECIALISTS. This lesson builds
run_with_handoff_guarded, the version with two explicit guards: a hard limit on handoffs per
request, and confirmation that the receiver exists before dispatching anything to it. You'll
actually trigger both cases and confirm both fail loud, with a clear message, instead of resending
the package indefinitely or silently.
Connection to the module
This lesson extends run_with_handoff from lesson 05 —it doesn't replace it: for the normal case,
with a single handoff, behavior is identical—. It reuses HandoffPackage and
run_specialist_with_handoff unchanged. Lesson 07 measures the cost of the "happy" path —exactly
the one this lesson confirms keeps working with the guard in place—.
Analogy: the bell that won't stop ringing
Imagine the sommelier, arriving at table 12, instead of answering the wine question, rings their own bell and hands it back to the waiter: "this is actually yours." If the waiter has no limit —no rule of "this already got transferred once, I won't hand it off again"— the two of them could end up passing the question back and forth forever without anyone ever answering it, while the table waits. A well-run restaurant has a simple rule to prevent this: if something bounces back after already being transferred once, someone —whoever holds the turn at that point— has to resolve it right there, not keep passing it along.
Worked example: the handoff-chain guard
import concurrent.futures
from dataclasses import dataclass, field
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)
]
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."
),
}
def search_docs(query):
q = query.lower()
if "no" in q and ("present" in q or "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}},
}
HANDOFF_TOOL_NAME = "handoff_to_specialist"
@dataclass
class HandoffPackage:
sender: str
receiver: str
reason: str
task: str
context: dict = field(default_factory=dict)
def run_agent_with_handoff(question, model_script, tools, self_name, 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, None
block = turn["content"][0]
if block["name"] == HANDOFF_TOOL_NAME:
inp = block["input"]
package = HandoffPackage(
sender=self_name, receiver=inp["receiver"], reason=inp["reason"],
task=inp["task"], context=inp.get("context", {}),
)
return None, messages, package
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 run_specialist_with_handoff(name, task, model_script):
tools = SPECIALISTS[name]["tools"]
return run_agent_with_handoff(task, model_script, tools, self_name=name)
MAX_HANDOFFS = 1
def run_with_handoff_guarded(name, task, model_scripts):
"""Same as run_with_handoff (lesson 05), with TWO guards: (1) at most
MAX_HANDOFFS handoffs per request -- if the receiver ALSO tries to
hand off the turn, it cuts with a clear error instead of resending
the package indefinitely; (2) SPECIALISTS[receiver] has to exist --
run_specialist_with_handoff already guarantees this with its own
KeyError, but here we make it explicit in the chain's message."""
current_name, current_task = name, task
handoffs = 0
trace = []
while True:
final, history, package = run_specialist_with_handoff(current_name, current_task, model_scripts[current_name])
trace.append({"agent": current_name, "history": history, "package": package})
if package is None:
return final, trace
handoffs += 1
if handoffs > MAX_HANDOFFS:
raise RuntimeError(
f"handoff chain exceeded: {package.sender} tried to hand off the turn "
f"to {package.receiver} after there were already {MAX_HANDOFFS} handoff(s) "
f"in this request -- possible ping-pong between agents"
)
current_name, current_task = package.receiver, package.task
# --- Case 1: ping-pong -- policy_agent, once it gets control, tries to
# hand it back to booking_agent instead of resolving the question ---
script_booking_handoff = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
"input": {"room": "Focus", "tier": "pro", "hours": 3}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_02", "name": HANDOFF_TOOL_NAME,
"input": {"receiver": "policy_agent", "reason": "policy question",
"task": "does the no-show policy apply to already-canceled bookings?",
"context": {"price_cents": 6000}}}]},
]
script_policy_pingpong = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": HANDOFF_TOOL_NAME,
"input": {"receiver": "booking_agent", "reason": "I need to know whether the booking is still active",
"task": "is booking 1 still active?", "context": {}}}]},
]
try:
run_with_handoff_guarded(
"booking_agent", "Quote Focus pro 3h. Does no-show apply to bookings that were already canceled?",
{"booking_agent": script_booking_handoff, "policy_agent": script_policy_pingpong},
)
except RuntimeError as e:
print(f"Case 1 (ping-pong) -- RuntimeError: {e}")
print()
# --- Case 2: handoff to a specialist that doesn't exist ---
script_booking_bad_receiver = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": HANDOFF_TOOL_NAME,
"input": {"receiver": "shipping_agent", "reason": "model typo",
"task": "email the contract", "context": {}}}]},
]
try:
run_with_handoff_guarded(
"booking_agent", "Email me the contract for my booking.",
{"booking_agent": script_booking_bad_receiver},
)
except KeyError as e:
print(f"Case 2 (nonexistent receiver) -- KeyError: {e!r}")
What to expect:
Case 1 (ping-pong) -- RuntimeError: handoff chain exceeded: policy_agent tried to hand off the turn to booking_agent after there were already 1 handoff(s) in this request -- possible ping-pong between agents
Case 2 (nonexistent receiver) -- KeyError: KeyError('shipping_agent')
Both cases fail loud, with a message that points straight at the cause —not a generic
"something went wrong" RuntimeError, nor a context-free KeyError—. Case 1 explicitly says who
tried to hand off the turn to whom, and why it got cut (possible ping-pong). Case 2 uses the same
KeyError you already saw in Module 2 (nonexistent SPECIALISTS[target]) and in Module 4
(SPECIALISTS[agent] from an invalid SubTask) — the same discipline from the whole guide, now
applied to the handoff.
The normal case still works the same way
Before assuming the guard "changed" the handoff's behavior, let's confirm the normal path —a single
handoff, like in lesson 05— still works exactly the same with run_with_handoff_guarded:
script_booking_ok = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "get_quote",
"input": {"room": "Focus", "tier": "pro", "hours": 3}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_02", "name": HANDOFF_TOOL_NAME,
"input": {"receiver": "policy_agent", "reason": "policy question, outside my expertise",
"task": "what happens if a member doesn't show up for a confirmed booking?",
"context": {"room": "Focus", "tier": "pro", "hours": 3, "price_cents": 6000}}}]},
]
script_policy_ok = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "search_docs",
"input": {"query": "no-show"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "We charge 50% of the quoted price."}]},
]
final, trace = run_with_handoff_guarded(
"booking_agent", "Quote Focus pro 3h. What happens if I don't show up?",
{"booking_agent": script_booking_ok, "policy_agent": script_policy_ok},
)
print("Case 0 (normal, a single handoff) -- OK, no exception")
print("agents in the trace:", [t["agent"] for t in trace])
print("final answer:", final["content"][0]["text"])
What to expect:
Case 0 (normal, a single handoff) -- OK, no exception
agents in the trace: ['booking_agent', 'policy_agent']
final answer: We charge 50% of the quoted price.
The guard doesn't add any cost to the happy path: with handoffs = 1, it never exceeds
MAX_HANDOFFS = 1, so the while finishes on the first pass, exactly like run_with_handoff from
lesson 05. The guard only activates when something exceeds the limit — never before.
Why MAX_HANDOFFS = 1, and not 0 or a higher number
MAX_HANDOFFS = 0 would make no handoff valid — we'd be back to failure mode 2 from lesson 02
(an immediate KeyError the moment anyone tried to hand off the turn), which is exactly what this
whole module exists to avoid. A higher number —say, MAX_HANDOFFS = 3— would allow longer chains
(booking_agent -> policy_agent -> pricing_agent, for example, if a policy question ended up
needing a price comparison), at the cost of making it harder to tell a legitimate chain from a real
ping-pong. This guide sets MAX_HANDOFFS = 1 on purpose, as the simplest and most common case:
one agent recognizes a limit, one specialist resolves it. A longer handoff chain —several
specialists in sequence, each resolving a part— starts looking more like a pipeline (M3) than a
one-off handoff, and deserves to be explicitly designed as one, not as an improvised chain of
hand-offs.
Common mistakes
-
Thinking the guard "fixes" the ping-pong instead of detecting it.
run_with_handoff_guardeddoesn't try to decide who's right betweenbooking_agentandpolicy_agent—that would require understanding the content of the dispute, something outside the scope of a mechanical guard—. It only detects that the limit was exceeded and fails loud, leaving the decision to whoever designs the system (probably, fixingpolicy_agent's script so it doesn't try to hand back). -
Confusing Case 1 (ping-pong,
RuntimeError) with Case 2 (nonexistent receiver,KeyError). They're two different kinds of error, with different causes: the first is a script-design problem (two agents handing off the turn to each other); the second is a data problem (a specialist name that isn't in the registry). Both fail loud, but for different reasons. -
Raising
MAX_HANDOFFSwithout thinking about the cost. Every extra handoff in a chain adds, at minimum, one more model call (the intermediate agent's handoff decision) — lesson 07 quantifies exactly that cost for a single handoff; a longer chain multiplies it. -
Forgetting that
run_with_handoff_guardedstill doesn't handle themax_iterationscase inside each agent. This lesson's two guards are about the chain between agents — if an individual agent enters too long an internal loop (more than its ownmax_iterations=10turns), it still fails withrun_agent_with_handoff'sRuntimeError, unrelated toMAX_HANDOFFS.
Exercises
Exercise 1: Confirm your own run (Easy)
Run this lesson's three cases (0, 1, and 2) yourself and confirm, line by line, that your output matches the "What to expect" blocks above.
See solution
There's no single "code solution" for this exercise — it's a verification: if your output matches the worked example's "What to expect" blocks exactly, your throwaway Reservo started clean and the three runs reproduced without deviation.
Exercise 2: Raise MAX_HANDOFFS to 2 and repeat Case 1 (Medium)
With MAX_HANDOFFS = 2, repeat Case 1 (the ping-pong between booking_agent and policy_agent).
Does it still fail? With what message, and after how many handoffs?
See solution
MAX_HANDOFFS_2 = 2
def run_with_handoff_guarded_v2(name, task, model_scripts, max_handoffs=MAX_HANDOFFS_2):
current_name, current_task = name, task
handoffs = 0
while True:
final, history, package = run_specialist_with_handoff(current_name, current_task, model_scripts[current_name])
if package is None:
return final
handoffs += 1
if handoffs > max_handoffs:
raise RuntimeError(
f"handoff chain exceeded: {package.sender} tried to hand off the turn "
f"to {package.receiver} after there were already {max_handoffs} handoff(s)"
)
current_name, current_task = package.receiver, package.task
try:
run_with_handoff_guarded_v2(
"booking_agent", "Quote Focus pro 3h. Does no-show apply to bookings that were already canceled?",
{"booking_agent": script_booking_handoff, "policy_agent": script_policy_pingpong},
)
except RuntimeError as e:
print(f"RuntimeError: {e}")
except KeyError as e:
print(f"KeyError: {e!r}")
Expected output:
RuntimeError: handoff chain exceeded: booking_agent tried to hand off the turn to policy_agent after there were already 2 handoff(s)
Explanation: with max_handoffs=2, the second handoff (policy_agent -> booking_agent) IS
allowed —handoffs reaches 2, which doesn't exceed the limit—, so the while runs
booking_agent a second time, with the task "is booking 1 still active?". Since
model_scripts["booking_agent"] still points to the same script_booking_handoff as always —a
fixed script, with no memory that it already ran once—, that second run repeats exactly the same
path: it quotes again (get_quote, a pure function, fine to repeat) and hands off the turn
again to policy_agent. Only then, on the third handoff, does handoffs reach 3, exceed
max_handoffs=2, and the RuntimeError cuts the chain. Raising the limit from 1 to 2 didn't
prevent the ping-pong — it just gave it one more lap before cutting it off. It's the same
conclusion the "Why MAX_HANDOFFS = 1, and not a higher number" section already previewed: the
limit detects the problem, it doesn't solve it — the real cause is still that policy_agent is
programmed to hand the turn back instead of resolving the question.
Exercise 3: Design a guard for Case 2 with a more specific message (Hard)
run_with_handoff_guarded lets Case 2's KeyError come straight out of
run_specialist_with_handoff, unchanged. Rewrite the while to catch that KeyError and re-raise
it with a message that includes the sender and reason of the handoff that failed —information
the original KeyError doesn't have, because SPECIALISTS[name] knows nothing about handoff
packages.
See solution
def run_with_handoff_guarded_v3(name, task, model_scripts):
current_name, current_task = name, task
handoffs = 0
while True:
final, history, package = run_specialist_with_handoff(current_name, current_task, model_scripts[current_name])
if package is None:
return final
handoffs += 1
if handoffs > MAX_HANDOFFS:
raise RuntimeError(f"handoff chain exceeded at {package.sender} -> {package.receiver}")
try:
current_name, current_task = package.receiver, package.task
# We confirm HERE, before the next lap, that the receiver exists --
# instead of letting the KeyError come from inside run_specialist_with_handoff.
if current_name not in SPECIALISTS:
raise KeyError(current_name)
except KeyError as e:
raise KeyError(
f"{package.sender} tried to hand off the turn to {package.receiver!r} "
f"(reason: {package.reason!r}), but that specialist doesn't exist in SPECIALISTS"
) from e
try:
run_with_handoff_guarded_v3(
"booking_agent", "Email me the contract for my booking.",
{"booking_agent": script_booking_bad_receiver},
)
except KeyError as e:
print(f"KeyError: {e}")
Expected output:
KeyError: "booking_agent tried to hand off the turn to 'shipping_agent' (reason: 'model typo'), but that specialist doesn't exist in SPECIALISTS"
Explanation: the original KeyError (KeyError('shipping_agent'), from Case 2) is correct but
minimal — it only says which key was missing, not who asked for it or why. Wrapping it with
raise ... from e preserves the original exception (visible in the full, chained traceback) while
adding the coordination context —sender and reason— that helps debug the real problem faster:
not just "this key doesn't exist," but "this agent, for this reason, asked for a key that doesn't
exist."
Summary and next step
run_with_handoff_guardedextends lesson 05's orchestrator with two guards: a hard limit on handoffs per request (MAX_HANDOFFS = 1), and implicit confirmation that the receiver exists inSPECIALISTS.- Confirmed by executing: a ping-pong between
booking_agentandpolicy_agentproduces a clearRuntimeError, with who tried to hand off to whom and after how many handoffs; a nonexistent receiver produces the same loudKeyErroryou already saw in Modules 2 and 4. - The normal case —a single handoff, like lesson 05's— still works exactly the same way: the guard adds no cost to the happy path.
MAX_HANDOFFS = 1is an explicit design choice: longer chains start looking like a pipeline (M3), and deserve to be designed as one, not as improvised hand-offs.
Next lesson: 07 — Measuring the cost of a direct handoff. We count, with real numbers, how much this same handoff costs compared to the alternative of going back to an external supervisor every time an agent hits a limit.
Additional resources
- Anthropic — Building effective agents — The principle of keeping explicit limits on any automatic coordination mechanism, so a badly calibrated decision fails in a controlled way instead of propagating forever.
- Python — Chained exceptions (
raise ... from) — The mechanism behind Exercise 3, which preserves the original exception while adding coordination context. - Python — Exceptions (
KeyError,RuntimeError) — The two exceptions this lesson actually triggers, the same "fail loud" discipline from the whole guide. - Python 3.14 — What's New — The version used to run every line of this lesson.