Chapter 21 · Capstone 2: Multi-Agent Customer Ticket System
Chapter goals:
- Build a multi-agent customer service system with
ToolLoopAgentthat can query tickets, create tickets, and escalate to experts- Master the工程化写法 of
tool+zodfor business tools- Control多步循环成本 with
stopWhen: isStepCount()- Learn the delegation模式 of "subagent wrapped as a tool" and cancel signal propagation
21.1 Scenario and Architecture Design
We will build a customer service system where the主 agent (Triage Agent) understands the user's诉求 and dispatches能力:
User ──▶ Main agent TriageAgent
│ tools:
├─ searchTicket query tickets
├─ createTicket create tickets
└─ consultExpert ──▶ Technical support subagent ExpertAgent
(独立 instructions + 独立 tools)Design principles:
- The主 agent only does routing and summarization; it does not directly handle technical details;
- 专业能力下沉 to tools: data operations are普通 tools; deep analysis goes to subagents;
- Loops must have an上限: prevent the model from反复 calling tools and spiraling cost.
21.2 Simulated Ticket Data and Basic Tools
First prepare in-memory data, then define two business tools with tool + zod:
import { tool } from 'ai';
import { z } from 'zod';
// In-memory "database"
const tickets = [
{ id: 'T-1001', title: 'Cannot log in', status: 'open', priority: 'high' },
{ id: 'T-1002', title: 'Export report errors', status: 'resolved', priority: 'medium' },
];
export const searchTicket = tool({
description: 'Query ticket list by ticket ID or status',
inputSchema: z.object({
ticketId: z.string().optional().describe('Ticket ID, e.g. T-1001'),
status: z.enum(['open', 'resolved']).optional().describe('Filter by status'),
}),
execute: async ({ ticketId, status }) => {
return tickets.filter(
(t) =>
(!ticketId || t.id === ticketId) && (!status || t.status === status),
);
},
});
export const createTicket = tool({
description: 'Create a new customer service ticket',
inputSchema: z.object({
title: z.string().describe('Issue title'),
priority: z.enum(['low', 'medium', 'high']).describe('Priority level'),
description: z.string().describe('Detailed description of the issue'),
}),
execute: async ({ title, priority, description }) => {
const newId = `T-${1000 + tickets.length + 1}`;
tickets.push({ id: newId, title, status: 'open', priority });
return { id: newId, status: 'created' };
},
});21.3 Subagent: ExpertAgent
For technical issues, delegate to an ExpertAgent with its own instructions and tools:
import { ToolLoopAgent, tool, createGateway } from 'ai';
import { z } from 'zod';
const gateway = createGateway({ apiKey: process.env.AI_GATEWAY_API_KEY ?? '' });
// Simulated knowledge base for expert queries
const techKnowledgeBase = [
{ topic: 'login', solution: 'Clear cache, reset password via email' },
{ topic: 'export', solution: 'Check data format, reduce row count' },
];
export const expertAgent = new ToolLoopAgent({
model: gateway('openai/gpt-5'),
instructions: `You are a technical support expert.
- Analyze the technical issue described by the triage agent
- Provide a concrete, actionable solution
- If the issue is not in your knowledge base, suggest an escalation path`,
tools: {
searchKnowledge: tool({
description: 'Search the technical knowledge base for solutions',
inputSchema: z.object({
topic: z.string().describe('Keyword to search'),
}),
execute: async ({ topic }) => {
return techKnowledgeBase.filter(k =>
k.topic.includes(topic.toLowerCase()),
);
},
}),
},
stopWhen: [
// Stop after 5 steps to control cost
{ type: 'isStepCount' as const, count: 5 },
],
});21.4 Wrap Subagent as a Tool
To let the主 agent delegate to the expert, wrap the subagent in a tool:
import { tool } from 'ai';
import { z } from 'zod';
import { expertAgent } from './expert-agent';
export const consultExpert = tool({
description: 'Consult the technical expert agent for complex issues. Use when the triage agent cannot resolve the issue directly.',
inputSchema: z.object({
issueSummary: z.string().describe('Summary of the technical issue'),
ticketId: z.string().describe('Associated ticket ID'),
}),
execute: async ({ issueSummary, ticketId }) => {
const result = await expertAgent.generate({
prompt: `Technical issue for ticket ${ticketId}: ${issueSummary}. Please analyze and provide a solution.`,
});
return {
ticketId,
expertAnalysis: result.text,
recommendedAction: result.text.includes('escalate') ? 'escalate_to_human' : 'apply_solution',
};
},
});Cancel Signal Propagation
When the主 agent's loop is aborted (e.g., user clicks "Stop"), the cancel signal must propagate to the subagent. AI SDK supports this via AbortSignal:
const controller = new AbortController();
// Pass the signal to both the主 agent and subagent
const result = await triageAgent.generate({
prompt: userMessage,
abortSignal: controller.signal,
});
// To cancel:
controller.abort();21.5 Main Triage Agent
import { ToolLoopAgent, createGateway } from 'ai';
import { searchTicket, createTicket } from './ticket-tools';
import { consultExpert } from './escalation-tool';
const gateway = createGateway({ apiKey: process.env.AI_GATEWAY_API_KEY ?? '' });
export const triageAgent = new ToolLoopAgent({
model: gateway('openai/gpt-5'),
instructions: `You are a customer service triage agent.
- Understand the user's issue from their message
- Search existing tickets to see if the issue is already reported
- If the issue is new, create a ticket
- If the issue is technical and complex, escalate to the expert agent
- Always be empathetic and professional
- Keep responses concise`,
tools: {
searchTicket,
createTicket,
consultExpert,
},
stopWhen: [
// Stop after 10 steps to control cost
{ type: 'isStepCount' as const, count: 10 },
],
});21.6 Full Integration
import 'dotenv/config';
import { triageAgent } from './triage-agent';
async function main() {
const userMessage = 'I cannot log in to my account. I tried resetting the password but it did not work.';
console.log(`User: ${userMessage}\n`);
const result = await triageAgent.generate({
prompt: userMessage,
});
console.log('Agent response:', result.text);
console.log('Steps taken:', result.steps.length);
console.log('Total tokens:', result.usage.totalTokens);
}
main().catch(console.error);21.7 Cost Control and Debugging
Monitoring Step Count
const result = await triageAgent.generate({
prompt: userMessage,
onStepEnd: ({ stepNumber, usage }) => {
console.log(`Step ${stepNumber}: ${usage.totalTokens} tokens`);
},
});Tool Call Inspection
for (const step of result.steps) {
for (const toolCall of step.toolCalls ?? []) {
console.log(`Tool: ${toolCall.toolName}, Args: ${JSON.stringify(toolCall.args)}`);
}
}21.8 Chapter Summary
- Multi-agent system: 主 agent (TriageAgent) routes to specialist tools and subagents;
- Subagent delegation: wrap
ToolLoopAgentin atoolso the主 agent can call it like any other tool; - Cost control:
stopWhen: [isStepCount(N)]prevents无限 loops; - Cancel propagation: pass
AbortSignalthrough the call chain for clean shutdown; - Engineering pattern:
tool+zodfor business tools; keep the主 agent focused on routing, not domain logic.
🧪 随堂测验
点击你认为正确的选项。答错时会展示正确答案与原因解析。
1. Why wrap a subagent in a tool instead of calling it directly?
2. How do you prevent the主 agent from looping infinitely when calling tools?
3. What is the purpose of passing AbortSignal to the agent?
4. What is the key design principle for the主 agent in a multi-agent system?
🛠️ Hands-On Exercises
- Extend the ticket system with a
updateTickettool that changes ticket status; verify the主 agent can create, search, and update tickets in a single conversation. - Add a second subagent (BillingExpertAgent) for billing issues; verify the主 agent routes technical vs billing issues to the correct subagent.
- Test cancel propagation: start a long-running conversation, click "Stop" mid-stream, verify all nested subagent calls are cancelled and no extra tokens are consumed.
Capstone 2 complete. Next: Capstone 3: Full-Stack Streaming Chat App.