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

  1. Thinking the guard "fixes" the ping-pong instead of detecting it. run_with_handoff_guarded doesn't try to decide who's right between booking_agent and policy_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, fixing policy_agent's script so it doesn't try to hand back).

  2. 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.

  3. Raising MAX_HANDOFFS without 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.

  4. Forgetting that run_with_handoff_guarded still doesn't handle the max_iterations case 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 own max_iterations=10 turns), it still fails with run_agent_with_handoff's RuntimeError, unrelated to MAX_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_guarded extends lesson 05's orchestrator with two guards: a hard limit on handoffs per request (MAX_HANDOFFS = 1), and implicit confirmation that the receiver exists in SPECIALISTS.
  • Confirmed by executing: a ping-pong between booking_agent and policy_agent produces a clear RuntimeError, with who tried to hand off to whom and after how many handoffs; a nonexistent receiver produces the same loud KeyError you 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 = 1 is 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

  1. 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.
  2. Python — Chained exceptions (raise ... from) — The mechanism behind Exercise 3, which preserves the original exception while adding coordination context.
  3. Python — Exceptions (KeyError, RuntimeError) — The two exceptions this lesson actually triggers, the same "fail loud" discipline from the whole guide.
  4. Python 3.14 — What's New — The version used to run every line of this lesson.