Customer Support Triage — Multi-Tier Routing with Escalation¶
Modules in play:
S.capture,C.none(),C.from_state(),Route,gate(),>>sequential
The Real-World Problem¶
Your support system has four specialist teams (billing, technical, account, general). Today a coordinator LLM reads every ticket and decides where to send it — burning API calls on a decision that could be deterministic. Worse, the billing specialist sees the classifier’s internal reasoning (“I determined this is a billing issue because…”) mixed into the customer’s actual message, producing confused responses. And when a ticket isn’t resolved, it silently closes instead of escalating to a human.
You need: deterministic routing (no LLM for dispatch), clean context isolation (specialists see only the customer message), and an escalation gate that pauses the pipeline for human review when resolution fails.
The Fluent Solution¶
from adk_fluent import Agent, Pipeline, S, C, gate
from adk_fluent._routing import Route
MODEL = "gemini-2.5-flash"
# Step 1: Classify intent — stateless, no conversation history
classifier = (
Agent("intent_classifier", MODEL)
.instruct(
"Classify the customer message into exactly one category: "
"'billing', 'technical', 'account', or 'general'.\n"
"Customer message: {customer_message}"
)
.context(C.none()) # Sees ONLY the captured message, not history
.writes("intent")
)
# Step 2: Specialized handlers — each sees only the customer message
billing_handler = (
Agent("billing_specialist", MODEL)
.instruct("Help with payment issues, refunds, subscriptions.\nMessage: {customer_message}")
.context(C.from_state("customer_message"))
.writes("agent_response")
)
technical_handler = (
Agent("tech_support", MODEL)
.instruct("Diagnose the issue, suggest troubleshooting steps.\nMessage: {customer_message}")
.context(C.from_state("customer_message"))
.writes("agent_response")
)
account_handler = (
Agent("account_manager", MODEL)
.instruct("Help with account access, profile updates, security.\nMessage: {customer_message}")
.context(C.from_state("customer_message"))
.writes("agent_response")
)
general_handler = (
Agent("general_support", MODEL)
.instruct("Help with FAQs, product info, general inquiries.\nMessage: {customer_message}")
.context(C.user_only())
.writes("agent_response")
)
# Step 3: Satisfaction check + escalation gate
satisfaction_check = (
Agent("satisfaction_monitor", MODEL)
.instruct("Evaluate if the issue was resolved. Set resolved to 'yes' or 'no'.")
.writes("resolved")
)
escalation = gate(
lambda s: s.get("resolved") == "no",
message="Issue unresolved. Escalating to human supervisor.",
)
# THE SYMPHONY: capture → classify → route → monitor → escalate
support_system = (
S.capture("customer_message")
>> classifier
>> Route("intent")
.eq("billing", billing_handler)
.eq("technical", technical_handler)
.eq("account", account_handler)
.otherwise(general_handler)
>> satisfaction_check
>> escalation
)
The Interplay Breakdown¶
Why S.capture() first?
The user’s message arrives as a conversation event, not a state key. Downstream
agents need it as {customer_message} in their prompts. S.capture("customer_message")
extracts the latest user message into state["customer_message"] — a one-liner
that replaces a 10-line custom BaseAgent subclass in native ADK.
Why C.none() on the classifier?
The classifier should see only the current message, not prior conversation turns.
Without C.none(), a returning customer’s history leaks in, and the classifier
might misroute based on a previous billing issue when the current issue is technical.
C.none() sets include_contents="none" on the native agent.
Why Route() instead of LLM routing?
The classifier already produced a string — "billing", "technical", etc.
Sending that string to another LLM to “decide” which specialist to call is
wasteful. Route("intent").eq("billing", billing_handler) dispatches instantly
and deterministically. Zero API cost, zero latency, 100% predictable. If you
later add a “returns” category, you add one .eq() line — no prompt engineering.
Why gate() at the end?
When resolved == "no", the pipeline should stop and wait for human intervention.
gate() creates a checkpoint: the pipeline pauses, emits the escalation message,
and resumes only when a human approves. Without this, unresolved tickets silently
close — the most dangerous failure mode in support systems.
Why C.from_state() on specialists?
Each specialist sees only customer_message — not the classifier’s output,
not other specialists’ responses, not internal state. This prevents prompt
contamination and keeps specialist responses focused.
Pipeline Topology¶
S.capture("customer_message")
──► intent_classifier [C.none]
──► Route("intent")
├─ "billing" → billing_specialist
├─ "technical" → tech_support
├─ "account" → account_manager
└─ otherwise → general_support
──► satisfaction_monitor
──► gate(resolved == "no") → [HUMAN ESCALATION]
Execution Sequence¶
sequenceDiagram
participant U as User
participant SC as S.capture
participant IC as intent_classifier
participant R as Route("intent")
participant BS as billing_specialist
participant TS as tech_support
participant GS as general_support
participant SM as satisfaction_monitor
participant GT as gate
U->>SC: "My bill is wrong"
Note right of SC: state["customer_message"] = input
SC->>IC: state[customer_message]
Note right of IC: C.none() — sees ONLY this message
IC->>IC: LLM call
Note right of IC: writes intent = "billing"
IC->>R: state[intent]
Note right of R: deterministic (no LLM call)
alt intent == "billing"
R->>BS: route
Note right of BS: C.from_state("customer_message")
BS->>BS: LLM call
Note right of BS: writes resolution
else intent == "technical"
R->>TS: route
TS->>TS: LLM call
else otherwise
R->>GS: route
GS->>GS: LLM call
end
BS->>SM: state[resolution]
SM->>SM: LLM call
Note right of SM: writes resolved = "yes" | "no"
alt resolved == "no"
SM->>GT: gate check
Note right of GT: Pipeline PAUSES — human escalation
else resolved == "yes"
Note over SM: Pipeline completes
end
Running on Different Backends¶
response = support_pipeline.ask("I was charged twice for my subscription")
print(response)
from temporalio.client import Client
client = await Client.connect("localhost:7233")
# Route() becomes deterministic workflow code (zero LLM cost for routing)
# gate() becomes a Temporal Signal (pauses for human approval)
durable = support_pipeline.engine("temporal", client=client, task_queue="support")
response = await durable.ask_async("I was charged twice for my subscription")
This pipeline is especially well-suited for Temporal because:
Route()is deterministic — replays identically from historygate()maps to Temporal Signals — pauses workflow for human inputEach specialist handler is a separate Activity — cached on replay
async_pipeline = support_pipeline.engine("asyncio")
response = await async_pipeline.ask_async("I was charged twice for my subscription")
Framework Comparison¶
Framework |
Lines |
Deterministic routing? |
Context isolation? |
Escalation gate? |
|---|---|---|---|---|
adk-fluent |
~45 |
|
|
|
Native ADK |
~100 |
Custom |
Manual |
Custom |
LangGraph |
~50 |
|
Manual state scoping |
Custom interrupt |
CrewAI |
~40 |
LLM-only (no deterministic) |
No isolation |
No built-in gate |