A Five-Agent Support Desk on Gemini 3.1 and ADK: Triage, Research, Action, QA and Orchestrator
Build a multi-agent customer support system with Google ADK and Gemini 3.1: typed handoffs, QA correction loops, Agent Engine or Cloud Run, sessions and costs.
This article is published in English.
Picture an order that has gone wrong. A customer writes that the wrong item arrived, asks for a refund, and also wants the correct item shipped the same day. Behind that single sentence sit three jobs: verify the order, process the return, and rush a replacement. Each needs its own data, its own policy and its own action. A lone support bot with one giant prompt can cope with a plain question and tends to stumble once several jobs are tangled together. It gets slow, brittle and error-prone.
The remedy is a multi-agent design: several specialists, each owning one slice of the workflow, with an orchestrator directing traffic. What follows builds one on Google Cloud with the Gemini 3.1 models and Google's Agent Development Kit (ADK). It covers how to carve out agent roles with clear boundaries, how an orchestrator routes, delegates and assembles results, how agents hand state to each other, and, just as important, when a single agent is still the better choice.
What goes wrong with one big prompt
Picture a support agent that began small and kept gaining abilities: retrieval over a knowledge base, a gentler tone for upset customers, a self-check pass. Its system prompt swells past 3,000 tokens, and every addition interferes with something already there. Four symptoms show up:
- Constraint bleed. Refund rules start applying to questions that have nothing to do with refunds, so a simple product query gets answered with return logic.
- Diluted attention. A model's attention is finite. Spread across dozens of instructions, most of them irrelevant to the query at hand, it gives each less weight.
- Impossible debugging. When a reply is wrong, it is unclear which of the many instructions caused it.
- One path for everything. Every query runs through the same pipeline whatever its difficulty.
Splitting the work gives each model its whole attention budget for the job in front of it. It thinks about refund policy only while processing refunds and about tone only while reviewing a draft.
ADK is designed around this idea. Its AutoFlow mechanism manages handoffs between specialists: when an orchestrator has sub-agents, ADK adds a transfer_to_agent() tool and writes descriptions of every available specialist into its context. The orchestrator therefore knows who is on its team and what each member is good for.
Shape of the system
Instead of a single prompt, a central orchestrator sits above a set of narrow sub-agents. The models need not match. The strongest reasoner goes where judgement matters (orchestration and quality review) and the fastest, cheapest one goes where speed matters (triage):
- Orchestrator: plans and delegates. Gemini 3.1 Pro. Its tools are the specialist agents.
- Triage agent: reads sentiment and intent and routes. Gemini 3.1 Flash-Lite, backed by NLP classifiers.
- Research agent: gathers facts and context. Gemini 3.1 Flash, with Vertex search and a CRM API.
- Action agent: carries out transactions such as refunds and updates. Gemini 3.1 Flash, with order and payment APIs.
- QA agent: checks facts and policy compliance. Gemini 3.1 Pro, with a policy knowledge base.
Gemini 2.5 Flash remains a good workhorse for single-agent flows. When reasoning quality drives routing decisions, though, the Gemini 3.1 line (generally available since early 2026) fits better, especially in the orchestrator and QA seats.
The Google Cloud pieces involved:
- Gemini 3.1 models as the reasoning core, tuned for tool use.
- Agent Development Kit as the framework that handles handoffs via AutoFlow.
- Vertex AI Agent Engine as a managed runtime for deploying and scaling agents.
- Firestore as shared session state where agents read and write conversation memory.
- Vertex AI Vector Search for retrieval over the knowledge base.
Setting up ADK
ADK supplies the scaffolding, including the handoff pattern in which one agent can effectively transfer the call to another. Installation is one line:
pip install google-adk google-generativeai google-cloud-firestore pydantic
Pydantic is listed explicitly because ADK relies on it for agent input and output schemas, and naming it keeps versions compatible.
Project layout deserves thought early. Multi-agent systems break most often at the seams: one agent sends a refund_id as an integer while the next expects a string. A flat layout with shared schemas prevents that class of bug:
support_agents/
├── main.py # Entry point for Cloud Run
├── common/
│ ├── __init__.py
│ └── schemas.py # Shared Pydantic models (the "handshake")
├── agents/
│ ├── __init__.py
│ ├── orchestrator.py
│ ├── triage.py
│ ├── research.py
│ ├── action.py
│ └── qa.py
└── config.py # Environment & project IDs
A hospital is a fair analogy. The orchestrator is the triage nurse, the research agent is the lab technician and the action agent is the surgeon. None of them talk to the patient simultaneously. They read and update the patient's chart, which here is Firestore, and schemas.py is the chart format they all agree on.
The triage agent
Triage acts as air traffic control. It solves nothing itself and only makes sure each request reaches the right specialist with the right priority.
Passing raw strings between agents invites breakage. A strict Pydantic schema means the orchestrator receives a validated Python object and never has to parse free text. The schema comes first:
# common/schemas.py
from pydantic import BaseModel, Field
class TriageResult(BaseModel):
intents: list[str] = Field(description="List of detected customer goals")
urgency: str = Field(pattern="^(low|medium|high|critical)quot;)
sentiment: str = Field(description="Current emotional state of the user")
required_agents: list[str] = Field(
description="List of sub-agents needed (research, action, etc.)"
)
Then the agent that produces it:
# agents/triage.py
from google.adk.agents import LlmAgent
from common.schemas import TriageResult
triage_agent = LlmAgent(
name="triage_agent",
model="gemini-3.1-flash-lite", # Fast and cheap for classification
instruction="""You are a triage specialist. Analyze the customer's
message and categorize it accurately.
Urgency rules:
- critical: Active outages or safety threats
- high: Frustrated users or financial issues > $100
- medium: Standard requests needing action
- low: General info/feedback
You must NOT attempt to solve the problem. Only classify it.""",
output_schema=TriageResult, # Force structured JSON output
output_key="triage_data"
)
Four design choices matter in that definition:
- One job. The agent never answers questions or takes actions, only classifies. That keeps the prompt short and accuracy high.
- Structured output.
output_schemaforces valid JSON matchingTriageResult, and ADK validates it through Pydantic automatically. - Several intents at once. Because
intentsis a list of strings, a message asking to update an address and also check the latest bill works, where a single-label classifier would fail. - State handoff.
output_key="triage_data"saves the result into session state, so the orchestrator reads it fromsession.state['triage_data'].
The research agent
The research agent is the investigator. It pulls raw data from Firestore and the retrieval pipeline, then condenses it into a structured brief for the action agent. The brief's schema is the contract between those two roles:
# common/schemas.py (add to existing file)
class ResearchBrief(BaseModel):
order_status: str = Field(description="Current order state")
customer_tier: str = Field(description="e.g. Gold, Standard")
applicable_policy: str = Field(description="Relevant policy text")
can_refund: bool = Field(description="Whether a refund is allowed")
reasoning: str = Field(description="Why this conclusion was reached")
Next come its tools and the agent definition:
# agents/research.py
from google.adk.agents import LlmAgent
from google.cloud import firestore
from common.schemas import ResearchBrief
db = firestore.Client()
def lookup_order(order_id: str) -> dict:
"""Fetch real-time shipping status and line items for an order ID."""
doc = db.collection('orders').document(order_id).get()
if doc.exists:
return doc.to_dict()
return {"error": f"Order {order_id} not found"}
def lookup_customer(customer_id: str) -> dict:
"""Retrieve customer profile, tier, and interaction history."""
doc = db.collection('customers').document(customer_id).get()
if doc.exists:
return doc.to_dict()
return {"error": f"Customer {customer_id} not found"}
def search_knowledge_base(query: str) -> list:
"""Search product docs and company policies via RAG pipeline."""
# Uses the RAG pipeline from our previous article
from rag_pipeline import pipeline
result = pipeline.query(query, top_k=5)
return result['sources']
research_agent = LlmAgent(
name="research_agent",
model="gemini-3.1-flash",
instruction="""You are a research specialist.
1. Extract identifiers (Order ID, Customer ID) from the triage data.
2. Use tools to gather order history and relevant company policies.
3. If policy info is missing, use search_knowledge_base.
CRITICAL: Do not answer the customer. Output a formal ResearchBrief.""",
tools=[lookup_order, lookup_customer, search_knowledge_base],
output_schema=ResearchBrief,
output_key="research_brief"
)
State travels without extra code. When the research agent starts, ADK injects triage_data from the previous step into its context, so it already knows it must look up order 12345 because triage identified that intent. Each agent builds on the previous one's work and nothing is done twice. One small production tip: ADK wraps plain functions as FunctionTool objects automatically, but wrapping them explicitly with from google.adk.tools import FunctionTool yields better latency tracking and token-usage logging in Vertex AI Agent Engine.
The action agent
Only this specialist holds write access to business APIs. Since it performs high-stakes operations, it gets strict schemas and explicit guardrails. The output schema first:
# common/schemas.py (add to existing file)
class ActionSummary(BaseModel):
actions_taken: list[str] = Field(description="List of actions executed")
refund_id: str | None = Field(default=None, description="Refund ID if issued")
escalated: bool = Field(default=False)
escalation_reason: str | None = Field(default=None)
Then the agent itself, which asks for human confirmation on high-value operations:
# agents/action.py
from google.adk.agents import LlmAgent
from google.adk.tools import FunctionTool
from common.schemas import ResearchBrief, ActionSummary
def process_refund(order_id: str, amount: float, reason: str) -> dict:
"""Process a financial refund. Required: valid order_id and amount."""
# In production: call your payment API
return {
"status": "processed",
"refund_id": f"REF-{order_id}",
"amount": amount
}
def create_replacement_order(
order_id: str,
item_id: str,
shipping_speed: str
) -> dict:
"""Create a replacement order with specified shipping speed."""
# In production: call your order management API
return {
"status": "created",
"new_order_id": f"REPL-{order_id}",
"estimated_delivery": "2 business days"
}
def escalate_to_human(reason: str, priority: str) -> dict:
"""Escalate to a human agent with full context."""
return {
"status": "escalated",
"queue": "priority" if priority == "high" else "standard"
}
# Wrap refund tool with human confirmation for high-value transactions
refund_tool = FunctionTool(
func=process_refund,
require_confirmation=True # Pauses for human approval before executing
)
action_agent = LlmAgent(
name="action_agent",
model="gemini-3.1-flash",
instruction="""You are an action specialist.
Review the 'research_brief' in the session state.
CRITICAL RULES:
1. Only refund if 'can_refund' is True in the research brief
2. If amount > $500, you MUST use escalate_to_human
3. You must call tools for every action — do not just tell the user
it happened
4. If information is missing from the brief, escalate""",
tools=[refund_tool, create_replacement_order, escalate_to_human],
input_schema=ResearchBrief, # Only sees research data, not raw chat
output_schema=ActionSummary,
output_key="action_result"
)
Two settings carry most of the safety. require_confirmation exists because a production system rarely lets an agent issue a refund on the strength of prompt instructions alone. ADK's tool confirmation flow pauses execution until a person approves, and the orchestrator sees a "pending" status it can route to a staff dashboard. input_schema matters because without it the action agent sees the entire conversation, outdated turns included. Restricting input to ResearchBrief stops stale context from bleeding in and keeps decisions anchored to the freshest data.
Note too that escalate_to_human is a deliberate business rule and no failure state. The orchestrator can see precisely why the agent stopped and forward the ticket to a person with the full research brief attached.
The QA agent
QA is the last gate. Beyond checking wording, it compares what the action agent did with what the research brief said, so that doing matches knowing.
This role runs on Gemini 3.1 Pro, since it must hold the whole conversation state in mind and catch subtle contradictions, work where Pro consistently beats Flash. ADK also lets it use thinking mode for deeper self-reflection before it approves a reply. The review schema:
# common/schemas.py (add to existing file)
class QAReview(BaseModel):
is_approved: bool = Field(description="True if the response meets all criteria")
feedback: str = Field(description="Specific corrections if not approved")
final_text: str = Field(description="The customer-facing message")
And the agent configuration:
# agents/qa.py
from google.adk.agents import LlmAgent
from google.adk.planners import BuiltInPlanner
from google.genai.types import ThinkingConfig
from common.schemas import QAReview
qa_agent = LlmAgent(
name="qa_agent",
model="gemini-3.1-pro",
instruction="""You are a senior QA auditor. Compare the 'action_result'
against the 'research_brief' in the session state.
CRITICAL CHECKS:
1. Did the Action Agent refund the EXACT amount listed in the brief?
2. Does the response cite the policy found during research?
3. Is the tone empathetic but professional?
4. Are ALL intents from the triage classification addressed?
5. No unauthorized promises, discounts, or policy exceptions
If any check fails, set is_approved to False with specific feedback.""",
output_schema=QAReview,
output_key="qa_review",
# MEDIUM for routine QA; switch to HIGH for edge cases or audits
planner=BuiltInPlanner(
thinking_config=ThinkingConfig(thinking_level="MEDIUM")
)
)
Rejection does not end the run. When qa_review.is_approved is false, the orchestrator reads the feedback field and routes back to the action or research agent with concrete corrections. This correction loop is what separates a production agent from a demo script. It is capped at two revision cycles, after which the case goes to a person.
The orchestrator
The orchestrator neither researches nor acts. It manages state and makes sure specialists run in a sensible order. Given sub_agents, ADK's AutoFlow adds the transfer_to_agent() tool, and the orchestrator's LLM picks the next specialist from its instructions and the current session state:
# agents/orchestrator.py
from google.adk.agents import LlmAgent
from agents.triage import triage_agent
from agents.research import research_agent
from agents.action import action_agent
from agents.qa import qa_agent
orchestrator = LlmAgent(
name="support_orchestrator",
model="gemini-3.1-pro", # Best reasoning for delegation decisions
instruction="""You are the lead coordinator of a customer support team.
Use the following specialists in order:
1. triage_agent: to classify the intent and urgency
2. research_agent: to gather order data and relevant policies
3. action_agent: to execute the required operations
4. qa_agent: to verify the final response
Special cases:
- If triage classifies as 'critical': bypass the chain and escalate
- If QA returns is_approved=False: re-route to the responsible agent
with the feedback (maximum 2 revision cycles, then escalate)
- If any agent fails: escalate to human with full context
You must never respond to the customer directly.
Always route through the specialist pipeline.""",
sub_agents=[triage_agent, research_agent, action_agent, qa_agent]
)
Running it
ADK executes agents through a Runner, which suspends execution whenever an agent invokes a tool, then restarts the agent once the result is back:
# main.py
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from agents.orchestrator import orchestrator
APP_NAME = "customer_support"
USER_ID = "system"
# For production, replace with a Firestore-backed session service
session_service = InMemorySessionService()
runner = Runner(
agent=orchestrator,
app_name=APP_NAME,
session_service=session_service
)
async def handle_customer_message(customer_id: str, message: str):
"""
Entry point for customer messages.
"""
session_id = f"support-{customer_id}"
# Ensure session exists
session = await session_service.get_session(
app_name=APP_NAME, user_id=USER_ID, session_id=session_id
)
if not session:
session = await session_service.create_session(
app_name=APP_NAME, user_id=USER_ID, session_id=session_id
)
# run_async yields events as agents execute
final_response = None
async for event in runner.run_async(
user_id=USER_ID,
session_id=session_id,
new_message=message
):
if event.is_final_response():
final_response = event
return {
"response": final_response.content.parts[0].text if final_response else None,
"session_id": session_id
}
Why not call agents directly? The Runner owns the whole loop. It persists sessions, tracks the active agent, processes tool calls and logs every step. Since each agent writes its output under an output_key, the Runner guarantees the research brief is in place when the action agent needs it, with no plumbing written by hand. Traceability comes along for free too: every agent call, tool use and handoff is logged as an event, which answers the debugging complaint raised earlier. When something goes wrong, the trail shows which agent decided what, and why.
Deployment paths
There are two ways to ship it. Vertex AI Agent Engine offers a managed runtime with almost nothing to operate, while Cloud Run gives full control of the container.
Agent Engine
This is the low-maintenance route, recommended for most ADK-native setups. Set two variables:
PROJECT_ID=your-project-id
LOCATION_ID=us-central1
Then deploy with one command:
adk deploy agent_engine \
--project=$PROJECT_ID \
--region=$LOCATION_ID \
--display_name="Customer Support Team" \
support_agents
Agent Engine takes care of scaling, session persistence and agent-aware trace logging. There is no Dockerfile, no session code and no infrastructure to look after.
Cloud Run
Choose this when custom system libraries, particular networking such as VPC Service Controls, or non-ADK components are needed. The example uses Quart in place of Flask. The syntax is nearly identical, but Quart handles async/await natively, which avoids the asyncio.run() anti-pattern of spinning up a fresh event loop for every request:
# server.py
from quart import Quart, request, jsonify
from main import handle_customer_message
app = Quart(__name__)
@app.route("/support", methods=["POST"])
async def support():
data = await request.get_json()
# No asyncio.run needed — Quart handles the event loop
result = await handle_customer_message(
data["customer_id"],
data["message"]
)
return jsonify(result)
@app.route("/health", methods=["GET"])
async def health():
return jsonify({"status": "healthy"})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080)
The container definition:
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8080
CMD ["python", "server.py"]
And the deploy command:
gcloud run deploy support-agents \
--source . \
--region us-central1 \
--memory 4Gi \
--cpu 2 \
--allow-unauthenticated
In short, Cloud Run takes medium setup effort (a Dockerfile and an API), needs manual state management with Firestore or Redis, logs to standard Cloud Logging and suits custom runtimes and frameworks. Agent Engine takes a single command, has sessions built in, produces agent-specific traces and suits ADK-native agents that need to scale fast.
Making conversations persist
The orchestrator example used InMemorySessionService, which is fine for testing and loses everything when the process restarts. Production needs a durable service. ADK offers two.
With Agent Engine, sessions are handled for you through the Vertex AI session service:
# config.py
from google.adk.sessions import VertexAiSessionService
session_service = VertexAiSessionService(
project="your-project-id",
location="us-central1",
agent_engine_id="your-agent-engine-id" # From adk deploy output
)
Agent Engine also summarises long histories and cleans up after itself, so no Firestore code is needed.
For Cloud Run, DatabaseSessionService works with a PostgreSQL or SQLite backend:
# config.py
from google.adk.sessions import DatabaseSessionService
# PostgreSQL for production
session_service = DatabaseSessionService(
db_url="postgresql+asyncpg://user:pass@host:5432/agents"
)
# Or SQLite for development
session_service = DatabaseSessionService(
db_url="sqlite+aiosqlite:///sessions.db"
)
It creates its tables on first start. Swap it into the Runner and session state survives restarts.
How state moves between agents
No manual get() and set() calls are needed. Each agent's output_key writes into session state automatically, and later agents read it from there:
triage_agent (output_key="triage_data")
↓ writes to session.state["triage_data"]
research_agent (reads triage_data, output_key="research_brief")
↓ writes to session.state["research_brief"]
action_agent (input_schema=ResearchBrief, output_key="action_result")
↓ writes to session.state["action_result"]
qa_agent (reads action_result + research_brief, output_key="qa_review")
The Runner persists that whole state to whichever backend is configured, whether Firestore, PostgreSQL or Agent Engine.
Three habits keep memory healthy:
- Summarise. Store condensed results instead of raw API responses, or the context window fills up with bloat.
- Expire sessions. Support sessions should carry a time to live of 24 to 48 hours, which keeps the database small and safer.
- Protect personal data. Raw PII such as national ID numbers or passwords has no place in shared state, so it should be masked before anything is written to the session.
When one agent is still the better answer
Multi-agent is not automatically superior. A practical way to decide:
- Simple FAQ or status check: single agent. Lowest latency and cost, no complex reasoning needed.
- Data enrichment with three to five lookups: single agent. One LLM call handles basic tool use fine.
- Regulated workflows: multi-agent. Isolation keeps action logic apart from user input, which limits prompt injection.
- Ambiguous routing: multi-agent. A dedicated triage agent that specialises in mapping intent reduces constraint bleed.
- High-stakes actions above about $500: multi-agent. It allows a double check by a QA agent before any API is hit.
- Audit requirements: multi-agent. Each agent's inputs and outputs are logged separately.
A rule of thumb: when a single-agent prompt approaches 3,000 tokens, the point where constraint bleed and attention dilution appeared, or when different parts of the workflow need different tools, it is time to split.
Common traps
- The everything agent. Performance drops once one agent holds ten or more tools, because models begin spending effort on choosing a tool instead of understanding the request. Split by capability: an agent that needs refund, shipping and legal tools becomes three specialists.
- Runaway context. If every agent sees the full ten-turn chat, token costs explode. Compact the state, so triage passes on only the detected intent and not the whole conversation.
- Endless handoffs. Agent A sends to B, which sends back to A, burning tokens in a circle. Give the orchestrator a maximum handoff counter; two cycles before human escalation works well.
- Fuzzy boundaries. Two agents grab the same task, or a task falls between them. Define explicit
input_schemaandoutput_schemacontracts with no overlapping duties. - Ignoring latency. A sequential pipeline adds 4 to 6 seconds, against 1 to 2 for a single agent. ADK's
ParallelAgentfans out independent work, and running research and compliance checks in parallel can trim 30 to 40% of total latency.
What to expect from it
Multi-agent systems trade latency for reliability on complex tasks. Published benchmarks and industry experience point to these strengths:
- Accuracy on complex tasks. Breaking multi-step problems into focused subtasks generally outperforms monolithic prompts, and ADK's documentation reports better completion rates when agents have narrow duties.
- Contained failures. When one component breaks, the damage stays inside that agent and does not ruin the whole reply.
- Auditability. Separate logs per agent make debugging and compliance straightforward.
The costs are just as real:
- Latency. A full five-agent pipeline takes roughly 3 to 8 seconds, against 1 to 2 for a single agent. Sequential calls are the main bottleneck.
- API spend. Five agents (an orchestrator and four specialists) cost about four to five times one agent per query, though model mixing keeps it in check. Triage on Flash-Lite at $0.25 per million input tokens, with Pro at $2 per million reserved for orchestration and QA, holds the blended cost per interaction under one cent.
- Thinking tokens. Pro's extended thinking (
thinking_level="HIGH") can add around 20K tokens, billed at the $12 per million output rate. One deep QA review therefore costs about $0.24 in thinking alone. Use HIGH selectively, since most QA passes are fine with MEDIUM. - Complexity. There are more moving parts to monitor. It pays off for complex workflows and is overkill for simple ones.
For a rough monthly bill at 100,000 complex queries:
- Orchestrator routing on Gemini 3.1 Pro: about $80
- Triage on Flash-Lite: about $20
- Research on Flash: about $40
- Action on Flash: about $40
- QA with MEDIUM thinking on Pro: about $100
- Cloud Run with 2 vCPU and 2 GiB: about $120
- PostgreSQL sessions: about $30
- Vector Search, shared with a single-agent setup: about $100
- Total: about $530 per month
Model mixing is the real cost lever. Only around 30% of calls (orchestrator and QA) hit Pro pricing, while the rest run on Flash or Flash-Lite at one-eighth to one-fiftieth the per-token price. The unit cost per complex interaction comes to roughly half a cent. These figures derive from Google Cloud's published pricing, and real bills shift with query complexity, thinking level and how deep agent calls go.
Order of adoption
The sensible sequence is to start with a single agent, tune its logic, ground it with retrieval, and split into specialists only once the system prompt grows too long or the tasks too varied for one model to handle reliably. Each layer builds on the one below.
The microservices moment for agents
Multi-agent design does not make AI more complicated. It makes complex workflows predictable, much as microservices replaced the monolith: one prompt becomes a distributed set of parts that each do one thing. Asking a single agent to triage, research, act and audit at once is like asking one person to be receptionist, lead analyst, technician and auditor together. That works in a company of one and fails in an enterprise.
Give each agent a focused job, specialised tools and a shared memory layer, and the result is accurate, because focused prompts cause less drift across unrelated tasks; debuggable, because the failing specialist is visible; and cheap, because a five-agent run on Gemini 3.1 costs about half a cent per complex interaction, well under a minute of a human agent's time.
The design also has room to grow. With the Agent-to-Agent (A2A) protocol, an open standard governed by the Linux Foundation, a system like this could eventually negotiate with agents run by vendors or partners, for example a support agent speaking straight to a shipping provider's agent to resolve a lost parcel with no human involved.
For now the advice is plain: build one agent first, and decompose when the prompt gets too long or the tasks too diverse.