Tampilan
10.8 Antrian Booking
Topologi: B — Agent + Workflow Materi yang dipakai: 4.1, 4.2, 4.3Prasyarat: 10.3
Masalahnya
Restoran menerima reservasi lewat WhatsApp. Prosesnya sudah baku dan tertulis di SOP:
- Validasi permintaan (jumlah orang wajar? tanggal valid?)
- Cek ketersediaan meja dan ketersediaan staf — dua sistem berbeda
- Kalau keduanya oke, konfirmasi. Kalau tidak, masukkan ke daftar tunggu
- Catat jejak untuk audit
Ini bukan pekerjaan agent. Kalau agent memutuskan sendiri urutannya, suatu hari ia akan mengkonfirmasi booking tanpa mengecek staf — dan restoran kelebihan tamu di malam Tahun Baru.
Tapi percakapannya tetap butuh agent: pelanggan menulis "besok malam bertiga ya" dan itu harus diterjemahkan jadi tanggal dan angka.
Keputusan arsitektur
Keputusan 1 — Pembagian agent dan workflow
Ini penerapan pertama Topologi B, dan pembagiannya adalah inti kasus ini.
| Lapisan | Tugas | Kenapa di situ |
|---|---|---|
| Agent | Memahami "besok malam bertiga" → { tanggal, jumlahOrang } | Butuh penafsiran bahasa |
| Workflow | Validasi → cek → putuskan → catat | Urutan tidak boleh bervariasi |
Aturannya: agent menentukan apa yang diminta, workflow menentukan bagaimana memprosesnya.
| Untung pemisahan ini | Rugi |
|---|---|
| Proses bisnis deterministik dan bisa diaudit | Dua komponen untuk dipahami |
| Bisa diuji dengan assertion biasa, tanpa scorer | Perlu memetakan keluaran agent ke input workflow |
| Perubahan SOP tidak menyentuh instruksi agent | |
| Alur terlihat sebagai diagram di Studio |
Keputusan 2 — Cek meja dan staf: berurutan atau paralel?
.then() berurutan | .parallel() | |
|---|---|---|
| Waktu total | Jumlah keduanya | Yang terlama saja |
| Kalau cek pertama gagal | Cek kedua tidak jalan — hemat | Tetap jalan — sia-sia |
| Kerumitan schema | Sederhana | Keluaran bersarang per id |
Pilihan: .parallel().
Kedua pemeriksaan memanggil sistem berbeda dan tidak saling bergantung. Berurutan berarti pelanggan menunggu dua kali lebih lama tanpa alasan.
Ongkosnya: kalau meja sudah penuh, pemeriksaan staf tetap berjalan sia-sia. Untuk dua panggilan cepat itu pertukaran yang sepadan — tapi kalau salah satu pemeriksaan mahal, berurutan justru lebih baik.
Keputusan 3 — Jejak audit: state atau dioper antar langkah?
| Workflow state | Dioper lewat schema | |
|---|---|---|
| Schema tiap langkah | Bersih | Ikut membawa jejak di semua langkah |
| Langkah bisa dihapus/ditambah | Mudah | Merusak rantai |
| Diverifikasi compiler | Sebagian | Penuh |
Pilihan: workflow state.
Ini persis kasus yang dijelaskan di 4.3: nilai yang dikumpulkan semua langkah dan dipakai di akhir. Mengopernya lewat schema berarti setiap langkah membawa field yang tidak ia pakai.
Data booking sendiri tetap lewat input/output antar langkah — karena ia mengalir antar langkah bertetangga.
Struktur direktori
text
booking-resto/
├── src/
│ ├── domain/
│ │ ├── ketersediaan.ts ← cek meja & staf
│ │ └── antrian.ts ← aturan waiting list
│ ├── infra/
│ │ ├── db.ts
│ │ └── whatsapp.ts
│ ├── mastra/
│ │ ├── agents/
│ │ │ └── resepsionis-agent.ts
│ │ ├── workflows/
│ │ │ └── booking-workflow.ts ← SATU berkas, seluruh alur
│ │ ├── tools/
│ │ │ └── proses-booking.ts ← jembatan agent → workflow
│ │ └── index.ts
│ └── server/
│ └── webhook.ts
└── package.jsonSeluruh workflow ada di satu berkas. Untuk alur enam langkah itu masih terbaca, dan menyebarnya ke enam berkas justru menyembunyikan alurnya — padahal alur itulah yang paling penting dipahami.
Pecah jadi beberapa berkas saat langkahnya melewati ~10, atau saat sebuah sub-alur dipakai ulang workflow lain.
Alur
Empat pola control flow dari 4.2 dalam satu alur: sekuensial, paralel, penggabungan, dan percabangan.
Implementasi
src/mastra/workflows/booking-workflow.ts
ts
import { createWorkflow, createStep } from '@mastra/core/workflows'
import { z } from 'zod'
import { cekMejaTersedia, cekStafCukup } from '../../domain/ketersediaan.ts'
const permintaanSchema = z.object({
nama: z.string(),
tanggal: z.string(),
jumlahOrang: z.number(),
})
// --- 1. Validasi ---
const validasi = createStep({
id: 'validasi',
inputSchema: permintaanSchema,
outputSchema: permintaanSchema.extend({ valid: z.boolean() }),
stateSchema: z.object({ jejak: z.array(z.string()) }),
execute: async ({ inputData, state, setState }) => {
await setState({ jejak: [...state.jejak, 'validasi'] })
return {
...inputData,
valid: inputData.jumlahOrang > 0 && inputData.jumlahOrang <= 20,
}
},
})
// --- 2a & 2b. Dua pemeriksaan paralel ---
const cekMeja = createStep({
id: 'cek-meja',
inputSchema: permintaanSchema.extend({ valid: z.boolean() }),
outputSchema: z.object({ mejaTersedia: z.number() }),
execute: async ({ inputData }) => ({
mejaTersedia: await cekMejaTersedia(inputData.tanggal, inputData.jumlahOrang),
}),
})
const cekStaf = createStep({
id: 'cek-staf',
inputSchema: permintaanSchema.extend({ valid: z.boolean() }),
outputSchema: z.object({ stafCukup: z.boolean() }),
execute: async ({ inputData }) => ({
stafCukup: await cekStafCukup(inputData.tanggal),
}),
})
// --- 3. Gabungkan hasil paralel ---
const putuskan = createStep({
id: 'putuskan',
// Keluaran paralel BERSARANG di bawah id tiap langkah.
inputSchema: z.object({
'cek-meja': z.object({ mejaTersedia: z.number() }),
'cek-staf': z.object({ stafCukup: z.boolean() }),
}),
outputSchema: z.object({ bisa: z.boolean(), mejaTersedia: z.number() }),
stateSchema: z.object({ jejak: z.array(z.string()) }),
execute: async ({ inputData, state, setState }) => {
const meja = inputData['cek-meja'].mejaTersedia
const staf = inputData['cek-staf'].stafCukup
await setState({ jejak: [...state.jejak, 'putuskan'] })
return { bisa: meja > 0 && staf, mejaTersedia: meja }
},
})
// --- 4a & 4b. Dua cabang, schema HARUS sama ---
const hasilSchema = z.object({ hasil: z.string() })
const keputusanSchema = z.object({ bisa: z.boolean(), mejaTersedia: z.number() })
const konfirmasi = createStep({
id: 'konfirmasi',
inputSchema: keputusanSchema,
outputSchema: hasilSchema,
stateSchema: z.object({ jejak: z.array(z.string()) }),
execute: async ({ state, setState }) => {
await setState({ jejak: [...state.jejak, 'konfirmasi'] })
return { hasil: 'dikonfirmasi' }
},
})
const antrikan = createStep({
id: 'antrikan',
inputSchema: keputusanSchema,
outputSchema: hasilSchema,
stateSchema: z.object({ jejak: z.array(z.string()) }),
execute: async ({ state, setState }) => {
await setState({ jejak: [...state.jejak, 'antrikan'] })
return { hasil: 'masuk-antrian' }
},
})
export const bookingWorkflow = createWorkflow({
id: 'booking',
inputSchema: permintaanSchema,
outputSchema: hasilSchema,
stateSchema: z.object({ jejak: z.array(z.string()) }),
})
.then(validasi)
.parallel([cekMeja, cekStaf])
.then(putuskan)
.branch([
[async ({ inputData }) => inputData.bisa === true, konfirmasi],
[async ({ inputData }) => inputData.bisa === false, antrikan],
])
.commit()Tiga hal yang paling sering salah, dan cara benarnya terlihat di atas.
Keluaran paralel bersarang. inputData['cek-meja'].mejaTersedia, bukan inputData.mejaTersedia. Ini penyebab undefined paling umum saat memakai .parallel().
Kedua cabang berbagi schema. keputusanSchema masuk, hasilSchema keluar — keduanya identik. Percabangan menuntut ini.
Kondisi cabang menutupi semua kemungkinan. === true dan === false bersama-sama menutup seluruh nilai boolean. Kalau cabang kedua ditulis sebagai kondisi lain yang tidak saling melengkapi, ada masukan yang lolos tanpa penanganan.
src/mastra/tools/proses-booking.ts
ts
import { createTool } from '@mastra/core/tools'
import { z } from 'zod'
export const prosesBooking = createTool({
id: 'proses-booking',
description:
'Proses permintaan reservasi meja. Panggil SETELAH nama, tanggal, dan ' +
'jumlah orang lengkap diketahui. BUKAN untuk menanyakan ketersediaan saja.',
inputSchema: z.object({
nama: z.string(),
tanggal: z.string().describe('Format YYYY-MM-DD'),
jumlahOrang: z.number().describe('Jumlah tamu, 1-20'),
}),
outputSchema: z.object({
hasil: z.enum(['dikonfirmasi', 'masuk-antrian', 'ditolak']),
}),
execute: async (input, { mastra }) => {
const wf = mastra.getWorkflow('bookingWorkflow')
const run = await wf.createRun()
const r = await run.start({
inputData: input,
initialState: { jejak: [] },
})
if (r.status !== 'success') {
return { hasil: 'ditolak' as const }
}
// Hasil branch dikunci berdasarkan id langkah yang jalan.
const keluaran = r.result as Record<string, { hasil: string }>
const nilai = Object.values(keluaran)[0]?.hasil
return { hasil: (nilai === 'dikonfirmasi' ? 'dikonfirmasi' : 'masuk-antrian') as const }
},
})Tool ini adalah jembatan antara agent dan workflow. Ia menerjemahkan hasil percakapan menjadi input workflow, lalu hasil workflow menjadi sesuatu yang bisa disampaikan agent.
Perhatikan pemeriksaan r.status !== 'success' — pola wajib dari 4.1.
src/mastra/agents/resepsionis-agent.ts
ts
import { Agent } from '@mastra/core/agent'
import { Memory } from '@mastra/memory'
import { prosesBooking } from '../tools/proses-booking.ts'
export const resepsionisAgent = new Agent({
id: 'resepsionis-agent',
name: 'Resepsionis',
instructions: `Kamu resepsionis restoran yang melayani reservasi lewat WhatsApp.
Kumpulkan tiga hal sebelum memproses: nama, tanggal, dan jumlah orang.
Kalau ada yang belum jelas, tanyakan — satu pertanyaan per pesan.
Ubah tanggal relatif ("besok", "sabtu depan") menjadi format YYYY-MM-DD.
Hari ini ${new Date().toISOString().slice(0, 10)}.
Setelah ketiganya lengkap, panggil prosesBooking.
Sampaikan hasilnya apa adanya:
- dikonfirmasi: beri selamat, sebutkan tanggal dan jumlah orang
- masuk-antrian: jelaskan bahwa penuh dan mereka masuk daftar tunggu
- ditolak: minta maaf dan sarankan menghubungi restoran langsung
JANGAN menjanjikan meja sebelum prosesBooking mengembalikan "dikonfirmasi".`,
model: 'openai/gpt-5-mini',
tools: { prosesBooking },
memory: new Memory({ options: { lastMessages: 10 } }),
})Kalimat terakhir adalah pengaman bisnis. Tanpa itu, agent yang ramah bisa berkata "baik, meja untuk 4 orang sudah saya siapkan!" sebelum workflow memastikan mejanya ada.
Hasil nyata
Workflow dijalankan sungguhan pada dua skenario. Tidak ada panggilan model di sini — workflow bersifat deterministik.
text
=== Sari, 2026-10-10, 4 orang ===
validasi: 4 orang pada 2026-10-10
cek-meja (paralel)
cek-staf (paralel)
putuskan: meja=5 staf=true -> BISA
konfirmasi: booking DITERIMA
STATUS: success
HASIL : {"konfirmasi":{"hasil":"dikonfirmasi"}}
=== Budi, 2026-12-31, 6 orang ===
validasi: 6 orang pada 2026-12-31
cek-meja (paralel)
cek-staf (paralel)
putuskan: meja=0 staf=true -> PENUH
antrikan: masuk WAITING LIST
STATUS: success
HASIL : {"antrikan":{"hasil":"masuk-antrian"}}Tiga hal yang terbukti:
1. Urutan selalu sama. Kedua skenario menjalankan validasi → paralel → putuskan, tanpa variasi. Itu yang tidak bisa dijamin agent.
2. Hanya satu cabang jalan. Skenario pertama tidak menyentuh antrikan sama sekali, dan sebaliknya.
3. Hasil dikunci berdasarkan id cabang yang jalan. Perhatikan bentuknya: {"konfirmasi":{...}} versus {"antrikan":{...}} — bukan objek datar.
Bentuk hasil branch mudah mengejutkan
Kode pemanggil tidak bisa menulis r.result.hasil — kuncinya adalah id langkah yang kebetulan jalan, dan itu berbeda antar eksekusi.
Karena itu proses-booking.ts memakai Object.values(keluaran)[0]. Alternatif yang lebih eksplisit: periksa keberadaan tiap kunci yang mungkin.
Perhatikan juga skenario Budi: STATUS: success meski bookingnya penuh. Workflow berhasil — yang "gagal" adalah bookingnya, dan itu hasil bisnis yang sah, bukan error teknis. Membedakan keduanya penting.
Best practice yang dipakai di sini
1. Satu berkas untuk seluruh alur. Alurnya sendiri adalah informasi paling penting; menyebarnya ke enam berkas menyembunyikannya.
2. Schema bersama untuk cabang. keputusanSchema dan hasilSchema didefinisikan sekali dan dipakai kedua cabang — mustahil tidak sinkron.
3. Jejak audit lewat state. Langkah bisa ditambah atau dihapus tanpa merusak rantai schema.
4. Tool sebagai jembatan. Agent tidak memanggil workflow langsung; ada satu tool yang menerjemahkan keduanya.
5. Larangan menjanjikan hasil di instruksi agent. Agent hanya boleh melaporkan apa yang dikembalikan workflow.
6. Status teknis dibedakan dari hasil bisnis. status: 'success' + hasil: 'masuk-antrian' adalah kombinasi yang benar.
Kapan pola ini salah
| Situasi | Kenapa gagal | Ke mana |
|---|---|---|
| Booking > 10 orang perlu persetujuan manajer | Workflow harus berhenti menunggu manusia | 10.9 Approval |
| Perlu memproses 50 permintaan sekaligus | Satu per satu terlalu lambat | .foreach() dengan concurrency |
| Cek ketersediaan makan 30 detik | Melewati batas wajar respons chat | Background task, 7.1 |
| SOP berubah tiap cabang restoran | Satu workflow tidak cukup | 10.10 Multi-tenant |
Coba sendiri
Perluas workflow ini dengan .foreach(): setelah booking dikonfirmasi, kirim notifikasi ke setiap tamu dalam rombongan yang punya nomor terdaftar.
Tambahkan juga langkah validasi yang menolak — bukan mengantrikan — kalau jumlah orang di luar 1–20.
Selesai kalau
- [ ] Alur memakai keempat pola:
.then(),.parallel(),.branch(),.foreach() - [ ] Tiga cabang berbeda bisa dicapai: dikonfirmasi, antrian, ditolak
- [ ]
.foreach()memakaiconcurrencyselain default, dan kamu bisa menjelaskan angkanya - [ ]
jejakdi akhir memuat semua langkah yang benar-benar jalan - [ ] Tidak ada masukan yang membuat workflow berhenti tanpa cabang terpilih
- [ ] Kamu bisa menjalankan seluruh workflow tanpa memanggil model
Kalau macet
Langkah setelah .parallel() menerima undefined? Keluarannya bersarang. Deklarasikan inputSchema dengan id tiap langkah paralel sebagai kunci.
Error tipe di .branch()? Kedua cabang harus punya inputSchema dan outputSchema yang identik. Definisikan sekali sebagai konstanta.
Workflow berhenti tanpa hasil? Tidak ada kondisi cabang yang terpenuhi. Pastikan kondisi-kondisinya menutupi seluruh kemungkinan.
Lanjut ke mana
Workflow ini berjalan dari awal sampai akhir tanpa henti. Kasus berikutnya menambahkan kemampuan yang paling khas dari workflow: berhenti menunggu manusia, lalu dilanjutkan berhari-hari kemudian dari proses yang berbeda — 10.9 Approval Pengeluaran.