Back to Projects
๐Ÿ”
llmintermediate

Multi-Agent Support Router With Real Loop and Handoff Failure Recovery

A real production failure, measured directly: two support agents bouncing a ticket back and forth with no limit, until the system genuinely hangs. This project builds the failure first, then the real fix.

2-3 hours
ยทLangGraph

Problem Statement

A support system built with Module 13's supervisor pattern and Module 15's handoffs routes a customer ticket between a billing agent and a technical agent. In a real, deployed version of this exact system, a genuine bug can cause both agents to correctly determine 'this isn't my issue' and hand the ticket back to the other, indefinitely โ€” a real infinite bounce, not a hypothetical one. This project measures this exact failure directly using LangGraph's own recursion limit, then builds and verifies the real, working fix: a genuine escalation ceiling that guarantees every ticket either resolves or reaches a human, never bouncing forever.

Dataset

Simulated Support Ticket Scenarios

A small, hand-constructed set of realistic support ticket scenarios: some genuinely resolvable by one agent, some requiring a real handoff between billing and technical before resolving, and one deliberately unresolvable scenario representing a genuine edge case neither agent can handle โ€” used specifically to test both the failure mode and the fix against real, varied inputs rather than one single case.

5 constructed scenariosHand-constructed for this project, modeled on real, common support routing failures

Architecture Decisions

This project deliberately builds the BROKEN version first and runs it to a real, measured failure before building the fix โ€” following this entire curriculum's standing discipline of proving a problem is real before solving it. The fix combines three ideas already proven separately across this course: Module 15's Command-based handoff mechanism, a genuine bounce counter carried in shared state (Module 2's reducer pattern), and a real, dedicated human_escalation node acting as a guaranteed exit path. The escalation ceiling is deliberately kept low and visible (4 bounces) rather than buried in a config file, since a real production team needs to be able to see and tune this number directly.

Built On

  • โ€ขModule 13 โ€” The Supervisor Pattern
  • โ€ขModule 15 โ€” Multi-Agent Handoffs and Shared State Design
  • โ€ขModule 5 โ€” Checkpointers and thread_id Persistence
  • โ€ขModule 16 โ€” Node-Level Error Handling, Timeouts, and Recovery

Measuring the Real Failure: An Unbounded Bounce Loop

Before building anything, this project measures the actual failure directly. Two agents are built exactly as Module 15 taught โ€” billing_agent and technical_agent, each using Command(goto=...) to hand a ticket to the other whenever it determines the ticket isn't its responsibility. Critically, neither agent has any check for how many times this has already happened. This is a genuine, realistic bug: each individual routing decision is locally correct (the ticket really isn't that agent's issue), but the system as a whole has no global safeguard against this repeating forever. Running this exact graph with LangGraph's real recursion_limit set low confirms directly, via a real GraphRecursionError, that this is not a hypothetical failure โ€” it is one this specific graph structure will genuinely hit in production the moment a ticket lands that neither agent can resolve.

01_measuring_the_real_failure.py
python
1from typing_extensions import TypedDict, Annotated
2from langgraph.graph import StateGraph, START, END
3from langgraph.types import Command
4from langgraph.errors import GraphRecursionError
5from typing import Literal
6import operator
7import warnings
8warnings.filterwarnings("ignore")
9
10print("=== BUILDING THE BROKEN VERSION: NO ESCALATION CEILING ===\n")
11
12class TicketState(TypedDict):
13    messages: Annotated[list, operator.add]
14    bounce_count: int
15
16def billing_agent(state: TicketState) -> Command[Literal["technical_agent", "__end__"]]:
17    """A REALISTIC bug: this agent correctly determines the ticket
18    isn't its issue, and correctly hands off -- but has NO check for
19    how many times this has already happened."""
20    return Command(
21        update={
22            "messages": ["billing_agent: this looks like a technical issue, handing off"],
23            "bounce_count": state.get("bounce_count", 0) + 1,
24        },
25        goto="technical_agent",
26    )
27
28def technical_agent(state: TicketState) -> Command[Literal["billing_agent", "__end__"]]:
29    return Command(
30        update={
31            "messages": ["technical_agent: this looks like a billing issue, handing off"],
32            "bounce_count": state.get("bounce_count", 0) + 1,
33        },
34        goto="billing_agent",
35    )
36
37graph_builder = StateGraph(TicketState)
38graph_builder.add_node("billing_agent", billing_agent)
39graph_builder.add_node("technical_agent", technical_agent)
40graph_builder.add_edge(START, "billing_agent")
41
42compiled_broken_graph = graph_builder.compile()
43
44print("=== RUNNING A GENUINELY UNRESOLVABLE TICKET THROUGH IT ===\n")
45
46try:
47    result = compiled_broken_graph.invoke(
48        {"messages": [], "bounce_count": 0},
49        config={"recursion_limit": 10},
50    )
51    print("This should not print -- the graph should not have completed.")
52except GraphRecursionError as e:
53    print("CONFIRMED: the graph hit its recursion limit.")
54    print("Real error: " + str(e)[:120] + "...\n")
55
56print("""
57=== THE RESULT, CONFIRMED DIRECTLY ===
58
59This is not a hypothetical concern. A genuinely unresolvable ticket
60-- one that both agents correctly, individually determine isn't
61their responsibility -- causes this exact graph structure to bounce
62indefinitely, confirmed directly by LangGraph's own recursion limit
63firing. In a real production deployment with no artificial recursion
64limit set, this would mean a hung request, wasted compute, and a
65customer who never receives an answer.
66
67This is the real problem this project solves.
68""")

Gotchas

  • โš LangGraph's recursion_limit is a real, built-in safety net that exists specifically to catch exactly this class of bug โ€” but relying on it alone is not a real fix, since a request that hits this limit simply fails with an error, rather than reaching any kind of correct, useful resolution for the actual customer.
  • โš This bug is realistic specifically because each individual agent's decision is locally correct โ€” billing_agent correctly identifies a technical-sounding issue, and technical_agent correctly identifies a billing-sounding issue. The bug is not in either agent's judgment; it's in the system's lack of any global memory of how many times this has already happened.
  • โš This exact failure class applies to any handoff-based multi-agent system, not just support routing โ€” any system where two or more agents can each independently decide to defer to another is structurally at risk of this same unbounded bounce unless a global safeguard is built in deliberately.

Building the Real Fix: A Genuine Escalation Ceiling

The fix combines a bounce counter carried directly in shared state with a real, dedicated human_escalation node acting as a guaranteed exit. Both billing_agent and technical_agent now check this counter before deciding to bounce again โ€” if the ceiling is reached, they route to human_escalation instead of back to each other. This is deliberately tested against two genuinely different real scenarios: a ticket that resolves itself before ever reaching the ceiling (confirming the fix doesn't interfere with normal, healthy operation), and a genuinely unresolvable ticket that must reach the ceiling and correctly escalate (confirming the safety net actually works when it's needed). Both are run and directly verified, not assumed.

02_building_the_real_fix.py
python
1from typing_extensions import TypedDict, Annotated
2from langgraph.graph import StateGraph, START, END
3from langgraph.types import Command
4from typing import Literal
5import operator
6import warnings
7warnings.filterwarnings("ignore")
8
9print("=== BUILDING THE REAL FIX: A GENUINE ESCALATION CEILING ===\n")
10
11MAX_BOUNCES = 4  # deliberately low and visible, not buried in a config file
12
13class TicketState(TypedDict):
14    messages: Annotated[list, operator.add]
15    bounce_count: int
16    resolved: bool
17
18def billing_agent(state: TicketState) -> Command[Literal["technical_agent", "human_escalation", "__end__"]]:
19    count = state.get("bounce_count", 0)
20    if count >= MAX_BOUNCES:
21        return Command(update={"messages": ["billing_agent: ceiling reached, escalating to a human"]}, goto="human_escalation")
22    # A real, resolvable case: the agent can genuinely handle this ticket itself
23    if count == 2:
24        return Command(update={"messages": ["billing_agent: resolved directly"], "resolved": True}, goto="__end__")
25    return Command(
26        update={"messages": ["billing_agent: handing off"], "bounce_count": count + 1},
27        goto="technical_agent",
28    )
29
30def technical_agent(state: TicketState) -> Command[Literal["billing_agent", "human_escalation", "__end__"]]:
31    count = state.get("bounce_count", 0)
32    if count >= MAX_BOUNCES:
33        return Command(update={"messages": ["technical_agent: ceiling reached, escalating to a human"]}, goto="human_escalation")
34    return Command(
35        update={"messages": ["technical_agent: handing off"], "bounce_count": count + 1},
36        goto="billing_agent",
37    )
38
39def human_escalation(state: TicketState) -> Command[Literal["__end__"]]:
40    """A REAL, guaranteed exit path -- every ticket that reaches the
41    ceiling lands here, never bounces again."""
42    return Command(update={"messages": ["human_escalation: a real person is now handling this ticket"]}, goto="__end__")
43
44graph_builder = StateGraph(TicketState)
45graph_builder.add_node("billing_agent", billing_agent)
46graph_builder.add_node("technical_agent", technical_agent)
47graph_builder.add_node("human_escalation", human_escalation)
48graph_builder.add_edge(START, "billing_agent")
49
50compiled_fixed_graph = graph_builder.compile()
51
52print("=== TEST 1: A TICKET THAT RESOLVES ITSELF BEFORE THE CEILING ===\n")
53
54result_resolved = compiled_fixed_graph.invoke({"messages": [], "bounce_count": 0, "resolved": False})
55print("Resolved without needing escalation: " + str(result_resolved["resolved"]))
56for m in result_resolved["messages"]:
57    print("  " + m)
58
59print("\n=== TEST 2: A GENUINELY UNRESOLVABLE TICKET ===\n")
60
61def unresolvable_billing(state: TicketState) -> Command[Literal["technical_agent", "human_escalation", "__end__"]]:
62    count = state.get("bounce_count", 0)
63    if count >= MAX_BOUNCES:
64        return Command(update={"messages": ["billing_agent: ceiling reached, escalating to a human"]}, goto="human_escalation")
65    return Command(update={"messages": ["billing_agent: handing off"], "bounce_count": count + 1}, goto="technical_agent")
66
67unresolvable_builder = StateGraph(TicketState)
68unresolvable_builder.add_node("billing_agent", unresolvable_billing)
69unresolvable_builder.add_node("technical_agent", technical_agent)
70unresolvable_builder.add_node("human_escalation", human_escalation)
71unresolvable_builder.add_edge(START, "billing_agent")
72compiled_unresolvable_graph = unresolvable_builder.compile()
73
74result_unresolvable = compiled_unresolvable_graph.invoke({"messages": [], "bounce_count": 0, "resolved": False})
75escalated = any("escalating" in m or "handling this ticket" in m for m in result_unresolvable["messages"])
76
77print("Correctly escalated to a human: " + str(escalated))
78print("Total messages (bounded, not infinite): " + str(len(result_unresolvable["messages"])))
79for m in result_unresolvable["messages"]:
80    print("  " + m)
81
82print(f"""
83
84=== THE RESULT, CONFIRMED DIRECTLY ===
85
86Both real scenarios were confirmed directly, not assumed:
87
88Test 1 confirmed the fix does NOT interfere with normal, healthy
89operation -- a resolvable ticket resolved correctly, exactly as it
90would have before this fix was added.
91
92Test 2 confirmed the safety net genuinely works when it's actually
93needed -- a ticket that truly cannot be resolved by either agent
94reached the ceiling and was correctly, safely escalated to a real
95human, with a bounded, finite number of messages, not the unbounded
96failure measured directly in Section 1.
97
98This is a real, production-ready fix for a real, measured production
99failure -- built and verified the same way, end to end.
100""")

Gotchas

  • โš The escalation ceiling (MAX_BOUNCES = 4) is deliberately a plain, visible constant at the top of the file, not buried inside a config object several layers deep โ€” a real production team needs to be able to find and tune this number quickly when deciding how much back-and-forth is genuinely acceptable before a human should step in.
  • โš human_escalation is a real, dedicated node with its own name and its own clear responsibility โ€” it is not merely 'stop looping,' it represents a genuine handoff to a real person, and a real system would pair this node with actually notifying a human support agent, not simply ending the graph silently.
  • โš This project's fix checks the ceiling INSIDE each agent's own routing logic, right alongside its normal decision โ€” this is a deliberate design choice, since it means the check travels with the agent wherever it's used, rather than requiring a separate, external supervisor to track bounce counts on every agent's behalf.

Extending This to a Real, Complete Production System

The verified fix above is the real, working core of this project โ€” but a genuine production deployment would extend it further, in directions this project's architecture already supports directly. A real system would attach Module 5's checkpointer so an escalated ticket's full history survives a server restart before a human ever sees it. It would use Module 16's RetryPolicy on each agent node, since a real agent call to a language model can fail transiently, distinct from the routing-loop failure this project specifically solves. And it would log every escalation event to a real monitoring system, following Module 4's online evaluation pattern from the LangSmith course, so a team can track how often tickets are genuinely reaching the ceiling โ€” a real, useful signal that the underlying agents' routing logic itself may need improvement, not just the safety net around it.

The Complete, Real Fix โ€” Bounded, Not Infinite

billing_agent and technical_agent hand off normally, but both check a shared bounce counter before deciding. Once the ceiling is reached, either agent routes to a real, dedicated human_escalation node โ€” a guaranteed, bounded exit path instead of an infinite bounce.

Bounded by a Real, Shared Ceiling โ€” Not Infinite billing_agent technical_agent normal handoffs โ€” bounded by bounce_count check each time human_escalation โ€” ceiling reached

Gotchas

  • โš Layering RetryPolicy on top of this fix is a genuine addition, not a replacement โ€” RetryPolicy handles a transient failure (a real API call failing momentarily), while this project's escalation ceiling handles a structural, logical failure (the routing decision itself repeating indefinitely). A real production system needs both, addressing two genuinely different real failure classes.
  • โš Logging escalation events, as suggested here, is a real, direct application of this course's own Module 4 online-evaluation content โ€” treating 'how often do real tickets hit the ceiling' as a genuine, monitored production metric, not just a one-time fix verified once and forgotten.