Tampilan
2.2 Schema yang Baik
Tujuan bab
Setelah bab ini kamu bisa:
- Menjelaskan kenapa schema tool berfungsi sebagai dokumentasi untuk model, bukan hanya validasi
- Menulis
descriptionyang membuat tool dipanggil di saat yang tepat - Memakai
.describe(),z.enum(), dan batasan nilai untuk mempersempit ruang tebak model - Memakai
toModelOutputuntuk memisahkan data lengkap bagi aplikasimu dari ringkasan bagi model - Menjelaskan masalah apa yang diselesaikan validasi, dan kenapa schema longgar menghasilkan jawaban salah tanpa error apa pun
- Mengenali tanda-tanda schema yang terlalu longgar
Prasyarat
Model memilih tool berdasarkan tulisanmu
Saat agent menerima pesan, ia harus memutuskan: perlukah memanggil tool, dan kalau ya, yang mana. Keputusan itu diambil berdasarkan tiga hal — pesan pengguna, instruksi agent, serta deskripsi dan schema tool.
Dua yang terakhir sepenuhnya kamu yang menulis. Artinya, saat tool tidak dipanggil padahal seharusnya, penyebabnya hampir selalu ada di tulisanmu, bukan di modelnya.
┌──────────────────────────┐
│ Model memutuskan │
│ tool mana yang dipakai │
└────────────┬─────────────┘
│ membaca
┌─────────────┼─────────────┐
▼ ▼ ▼
Pesan Instruksi description
pengguna agent + inputSchema
(dari user) (kamu tulis) (kamu tulis)description yang bekerja
Bandingkan dua deskripsi untuk tool yang sama:
ts
// Lemah
description: 'Tool cuaca'
// Kuat
description:
'Ambil kondisi cuaca terkini untuk satu kota tertentu. ' +
'Pakai saat pengguna menanyakan cuaca, suhu, atau kondisi langit saat ini. ' +
'Tidak untuk prakiraan beberapa hari ke depan.'Yang membuat versi kedua bekerja:
- Menyebut apa yang dikembalikan — "kondisi cuaca terkini", bukan sekadar topiknya
- Menyebut kapan dipakai — situasi konkret yang memicunya
- Menyebut batasnya — kalimat terakhir mencegah tool ini dipanggil untuk pertanyaan prakiraan, yang seharusnya ditangani tool lain
Kalimat batas itu makin penting seiring bertambahnya tool. Dengan satu tool, model tidak punya pilihan lain. Dengan lima tool yang deskripsinya saling tumpang tindih, model akan sering memilih yang salah — dan yang perlu diperbaiki adalah deskripsinya, bukan instruksinya.
Uji cepat untuk deskripsi
Bacakan description tool-mu kepada seseorang yang tidak tahu isi kodenya, lalu tanyakan: "kalau pengguna bertanya X, apakah kamu akan memakai tool ini?" Kalau dia ragu, model juga akan ragu.
inputSchema mempersempit ruang tebak
Schema input punya dua tugas sekaligus: memberitahu model bagaimana memanggil tool, dan menolak panggilan yang bentuknya salah. Semakin sempit schema-mu, semakin sedikit yang harus ditebak model.
ts
// src/mastra/tools/laporan-tool.ts
import { createTool } from '@mastra/core/tools'
import { z } from 'zod'
export const laporanTool = createTool({
id: 'buat-laporan-penjualan',
description:
'Hasilkan laporan penjualan untuk satu periode dan satu wilayah. ' +
'Pakai saat pengguna meminta angka penjualan, bukan saat meminta prakiraan.',
inputSchema: z.object({
periode: z
.enum(['harian', 'mingguan', 'bulanan'])
.describe('Rentang waktu laporan'),
wilayah: z
.enum(['jakarta', 'surabaya', 'medan', 'semua'])
.describe('Wilayah penjualan. Pakai "semua" bila pengguna tidak menyebut wilayah'),
sertakanGrafik: z
.boolean()
.default(false)
.describe('Sertakan data grafik dalam hasil. Default false'),
}),
outputSchema: z.object({
periode: z.string(),
wilayah: z.string(),
totalPenjualan: z.number(),
jumlahTransaksi: z.number(),
}),
execute: async ({ periode, wilayah, sertakanGrafik }) => {
return {
periode,
wilayah,
totalPenjualan: 125_000_000,
jumlahTransaksi: sertakanGrafik ? 342 : 342,
}
},
})Tiga teknik yang bekerja di sini:
z.enum() alih-alih z.string(). Kalau wilayah bertipe string bebas, model bisa mengirim "Jakarta", "DKI Jakarta", "jkt", atau "Jakarta Pusat" — semuanya lolos validasi, dan kodemu harus menormalkan semuanya. Dengan enum, ruang nilainya tertutup.
.describe() yang memberi aturan, bukan sekadar nama. Perhatikan deskripsi wilayah: ia tidak hanya bilang "wilayah penjualan", tapi memberitahu apa yang harus dilakukan model kalau pengguna tidak menyebut wilayah. Tanpa itu, model akan menebak — kadang memilih satu wilayah acak, kadang balik bertanya.
.default() untuk yang opsional. Ini menghilangkan satu keputusan dari pundak model.
Ringkasan taktiknya
| Alih-alih | Pakai | Karena |
|---|---|---|
z.string() untuk nilai terbatas | z.enum([...]) | Menutup ruang nilai |
z.number() tanpa batas | .min() / .max() | Memberi skala yang jelas |
| Field opsional tanpa default | .default(...) | Mengurangi keputusan model |
| Nama field yang disingkat | Nama yang utuh dan deskriptif | Nama field ikut dibaca model |
Tanpa .describe() | .describe() pada field yang ambigu | Menjelaskan maksud, bukan tipe |
Kenapa validasi itu mendesak
Sampai sini "input tervalidasi" mungkin terdengar seperti istilah kerapian. Bagian ini menunjukkan masalah nyata yang diselesaikannya — dengan sistem yang benar-benar rusak, lalu diperbaiki.
Masalahnya: sistem yang memberi jawaban salah tanpa error
Sebuah asisten pemesanan katering. Aturan bisnisnya sederhana: selalu masak 5 porsi cadangan di atas jumlah peserta.
ts
// domain/katering.ts — logika bisnis
const HARGA_PER_ORANG = 50_000
const CADANGAN = 5
export function hitungPesanan(jumlahPeserta) {
const totalPorsi = jumlahPeserta + CADANGAN
return { totalPorsi, biaya: totalPorsi * HARGA_PER_ORANG }
}Tool-nya memakai schema yang terlihat wajar:
ts
inputSchema: z.object({
jumlahPeserta: z.string().describe('Jumlah peserta'),
})Pengguna mengetik:
text
Tolong pesankan katering untuk rapat, pesertanya dua puluh lima orang.Ini yang dijawab sistem — hasil nyata, dijalankan sungguhan:
text
Pesanan katering untuk 25 peserta telah dibuat.
- Total porsi: 255 porsi
- Total biaya: Rp 12.750.000
Mau saya konfirmasi detail lain (menu, waktu pengantaran, alamat)?Baca ulang kalimat pertamanya: "untuk 25 peserta". Sistem tahu jumlahnya 25. Lalu ia memesan 255 porsi seharga Rp12.750.000.
Untuk rapat 25 orang.
Tidak ada satu pun error di sepanjang jalur ini
Tidak ada exception. Tidak ada peringatan. Tidak ada nilai undefined atau NaN yang mencurigakan. Tool berhasil, agent menjawab dengan percaya diri, dan kalimatnya terdengar benar.
Kalau ini terhubung ke sistem pemesanan sungguhan, katering untuk 255 orang benar-benar datang.
Apa yang sebenarnya terjadi
Jejak di dalam tool memperlihatkan penyebabnya:
text
[terima] jumlahPeserta="25" (string)
["25" + 5 = "255"]Model mengirim "25" — string, bukan angka. Itu bukan kesalahan model: schema-nya memang meminta z.string(), dan model menurut.
Lalu hitungPesanan() menjalankan jumlahPeserta + CADANGAN. Dalam JavaScript, "25" + 5 bukan penjumlahan — itu penggabungan string. Hasilnya "255".
Baris berikutnya, "255" * 50000, justru berhasil — JavaScript memaksa string jadi angka untuk perkalian. Jadi biayanya terhitung "benar" dari porsi yang salah.
Perbaikannya: satu kata
ts
// SEBELUM
jumlahPeserta: z.string().describe('Jumlah peserta')
// SESUDAH
jumlahPeserta: z.number().int().min(1).max(500)
.describe('Jumlah peserta sebagai ANGKA')Logika bisnis tidak disentuh sama sekali. hitungPesanan() persis sama.
Hasil nyata setelah perubahan itu:
text
[terima] jumlahPeserta=25 (number)
[25 + 5 = 30]
Pesanan katering berhasil dibuat untuk 25 peserta.
- Total porsi: 30
- Total biaya: Rp1.500.000 (Rp50.000 per porsi)| Sebelum | Sesudah | |
|---|---|---|
| Yang dikirim model | "25" (string) | 25 (number) |
| Total porsi | 255 | 30 |
| Total biaya | Rp12.750.000 | Rp1.500.000 |
| Error yang muncul | tidak ada | — |
| Logika bisnis | tidak berubah | tidak berubah |
Selisihnya 8,5 kali lipat, dan tidak ada apa pun di sistem yang memberitahumu.
Jadi apa yang divalidasi?
Pertanyaan yang wajar: apa persisnya yang diperiksa?
Yang diperiksa adalah bentuk data yang menyeberang dari model ke kodemu. Model menghasilkan teks; teks itu diurai menjadi argumen tool. Di titik penyeberangan itulah validasi berdiri.
text
model menyusun panggilan tool
{ "jumlahPeserta": "25" }
│
▼
╔═══════════════════════╗
║ inputSchema ║ ← di SINI diperiksa:
║ ║ - tipenya benar? (number, bukan string)
║ z.number() ║ - nilainya masuk akal? (1-500)
║ .int().min(1) ║ - bilangan bulat?
║ .max(500) ║
╚═══════════════════════╝
│
▼
seluruh kodemu boleh berasumsi
jumlahPeserta ADALAH number 1-500Tanpa garis itu, setiap fungsi setelahnya harus curiga sendiri — dan satu fungsi yang lupa sudah cukup untuk memesan katering 255 porsi.
Kenapa bug ini sulit tertangkap
Ini bagian yang membuatnya berbahaya. Tipe yang salah tidak merusak semuanya — ia merusak sebagian.
operasi pada "25" | hasil | benar? |
|---|---|---|
* 50000 | 1250000 | ✅ sama dengan number |
> 20 | true | ✅ sama |
/ 2 | 12.5 | ✅ sama |
Math.max(v, 10) | 25 | ✅ sama |
+ 5 | "255" | ❌ salah diam-diam |
.toFixed(0) | TypeError | ❌ crash |
JSON.stringify | {"peserta":"25"} | ❌ API menolak |
Empat dari tujuh operasi memberi hasil identik. Itulah kenapa bug ini lolos dari testing: kamu menguji perhitungan biaya — lolos. Kamu menguji pengecekan kapasitas — lolos. Yang rusak justru operasi yang jarang kamu uji.
Kenapa ini lebih mendesak pada agent
Kamu mungkin berpikir input dari pengguna juga tidak tepercaya, dan itu sudah biasa ditangani. Ada tiga hal yang membedakan.
| Form biasa | Argumen dari model | |
|---|---|---|
| Bentuk masukan | Tetap, kamu yang mendesain | Disusun ulang tiap panggilan |
| Bisa direproduksi | Ya | Tidak selalu — non-deterministik |
| Ketahuan saat testing | Biasanya | Sering tidak — kamu uji 5 kalimat, pengguna menulis 5.000 |
| Sumber kesalahan | Pengguna salah ketik | Model menafsir kalimat |
Baris ketiga yang menentukan. Pada form, kolom "jumlah" selalu mengirim bentuk yang sama. Pada agent, bentuk argumen disusun ulang model setiap kali berdasarkan kalimat yang berbeda-beda — dan kamu tidak bisa menguji semua cara orang menulis.
Perhatikan juga: pada percobaan di atas, model tidak salah. Ia mengirim "25" karena schema memintanya. Validasi bukan soal ketidakpercayaan pada model — soal menutup celah antara apa yang kamu minta dan apa yang kodemu asumsikan.
Aturan praktisnya
Setiap field yang akan dipakai dalam aritmetika, perbandingan, atau dikirim ke sistem lain harus punya tipe yang tepat di schema. Bukan z.string() yang "nanti di-parse".
Biaya menulis z.number().int().min(1) adalah sepuluh detik. Biaya tidak menulisnya, pada contoh di atas, adalah Rp11.250.000.
outputSchema juga penting
Godaan untuk menulis outputSchema: z.any() besar, karena toh kodemu yang menghasilkan datanya. Tapi output schema mendeskripsikan bentuk hasil yang kembali ke agent, dan agent menyusun jawabannya dari situ. Output yang strukturnya jelas menghasilkan jawaban akhir yang lebih tepat.
Ada juga manfaat yang lebih membumi: outputSchema adalah kontrak. Saat tool yang sama dipakai lima agent dan suatu hari kamu mengubah bentuk hasilnya, schema itu yang memberitahu apa yang rusak.
Memisahkan data aplikasi dari data model
Kadang tool-mu mengembalikan data yang kaya untuk aplikasi, tapi model tidak perlu semuanya. Mengirim seluruh respons API mentah ke model itu boros dan justru mengaburkan yang penting.
Untuk itu ada toModelOutput:
ts
// src/mastra/tools/weather-tool.ts
export const weatherTool = createTool({
// ...id, description, schema...
execute: async ({ location }) => {
const response = await fetch(`https://wttr.in/${location}?format=j1`)
const data = await response.json()
return {
location,
temperatureCelsius: Number(data.current_condition[0].temp_C),
conditions: data.current_condition[0].weatherDesc[0].value,
weatherIconUrl: data.current_condition[0].weatherIconUrl[0].value,
source: data,
}
},
toModelOutput: output => {
return {
type: 'content',
value: [
{
type: 'text',
text: `${output.location}: ${output.temperatureCelsius}°C dan ${output.conditions}`,
},
{ type: 'image-url', url: output.weatherIconUrl },
],
}
},
})Yang terjadi: aplikasimu tetap menerima hasil lengkap termasuk source berisi respons API mentah, sementara model hanya menerima satu kalimat ringkas beserta ikon. Konteks model tetap fokus, datamu tetap utuh.
┌──────────────────────────────┐
execute() ──────▶│ Hasil lengkap │
│ location, suhu, kondisi, │──▶ Aplikasimu
│ ikon, source (API mentah) │
└───────────┬──────────────────┘
│ toModelOutput()
▼
┌──────────────────────────────┐
│ "Surabaya: 31°C dan cerah" │──▶ Model
│ + ikon │
└──────────────────────────────┘Ada mekanisme serupa bernama transform, tapi tujuannya berbeda dan keduanya mudah tertukar:
| Menyaring untuk | Contoh pemakaian | |
|---|---|---|
toModelOutput | Model | Meringkas respons API besar jadi satu kalimat |
transform | Tampilan UI dan transkrip | Menyensor data rahasia sebelum tampil ke pengguna |
Satu catatan penting soal transform dari dokumentasi: kalau transform dikonfigurasi lalu gagal, Mastra tidak jatuh kembali ke payload mentah untuk target tampilan dan transkrip. Ini perilaku yang aman secara default — lebih baik tidak menampilkan apa-apa daripada tanpa sengaja membocorkan data yang seharusnya disensor.
Opsi lain yang berguna diketahui
Beberapa properti createTool() yang tidak wajib tapi layak kamu kenal:
strict— saat bernilai true, Mastra mengaktifkan pembangkitan input ketat pada adapter model yang mendukungnya, sehingga argumen yang dikirim lebih patuh pada schemainputExamples— contoh input yang valid, yang bisa dipakai sebagian penyedia model sebagai acuanrequestContextSchema— memvalidasi nilairequestContextsebelumexecuteberjalan, dan mengembalikan objek error kalau tidak validproviderOptions— opsi khusus penyedia yang dikirim ke model saat tool ini dipakai
Kesalahan umum
Gejala: Ada dua tool dengan fungsi berdekatan, dan agent konsisten memilih yang salah — misalnya selalu memakai tool pencarian saat pengguna sebenarnya minta detail satu item. Penyebab: Deskripsi keduanya tumpang tindih dan tidak menyebutkan batas masing-masing. Model memilih yang deskripsinya paling mirip dengan pertanyaan, dan tanpa pembeda yang jelas, kemiripan itu sering menyesatkan. Perbaikan: Tambahkan kalimat batas di kedua deskripsi yang secara eksplisit menyebutkan kapan tool itu tidak dipakai, sambil menunjuk ke tool saudaranya. Ini lebih efektif daripada menambah aturan di instruksi agent.
Gejala: Tool berhasil dipanggil tapi argumennya sering aneh — enum yang diisi nilai di luar daftar, atau field opsional yang diisi tebakan padahal pengguna tidak menyebutkannya. Penyebab: Schema terlalu longgar. Field bertipe z.string() untuk nilai yang sebenarnya terbatas, atau field opsional tanpa .default() sehingga model merasa harus mengisinya. Perbaikan: Perketat dengan z.enum(), beri .default() pada yang opsional, dan tambahkan .describe() yang menyebutkan apa yang harus dilakukan saat informasi tidak tersedia — misalnya "pakai semua bila pengguna tidak menyebut wilayah".
Coba sendiri
Tantangan: Ambil dua tool dari Coba sendiri bab 2.1 — cari-buku dan cek-denda — lalu tambahkan tool ketiga yang sengaja mirip dengan yang pertama: detail-buku, yang menerima ISBN dan mengembalikan informasi lengkap satu buku.
Ketiganya sekarang harus dibedakan dengan jelas oleh model.
Ketentuan:
- Tulis ulang
descriptionketiga tool sehingga masing-masing menyebutkan kapan dipakai dan kapan tidak dipakai - Ganti setiap field yang nilainya terbatas menjadi
z.enum() - Tambahkan
.describe()pada setiap field yang maksudnya bisa ditafsirkan lebih dari satu cara, termasuk aturan untuk kasus informasi tidak tersedia - Tambahkan
toModelOutputpadadetail-bukusehingga aplikasi menerima seluruh metadata buku, tapi model hanya menerima judul, penulis, tahun, dan status ketersediaan - Uji dengan lima pesan yang berada di perbatasan antar tool, misalnya "ada buku Laskar Pelangi?", "info lengkap ISBN 978-979-3062-79-2", dan "buku apa saja yang saya pinjam dan telat?"
Checklist penerimaan:
- [ ] Kelima pesan uji memicu tool yang tepat — periksa di Studio, jangan hanya menilai dari jawaban akhirnya
- [ ] Tidak ada
z.string()yang tersisa untuk field bernilai terbatas - [ ] Setiap deskripsi tool memuat kalimat batas yang menyebut kapan ia tidak dipakai
- [ ] Di Studio, hasil tool
detail-bukuyang diterima model terlihat lebih ringkas daripada yang dikembalikanexecute - [ ] Untuk pesan yang informasinya kurang (misalnya "cari buku" tanpa kata kunci), agent bertanya balik alih-alih memanggil tool dengan argumen karangan
Petunjuk: Checklist terakhir sering gagal di percobaan pertama. Kalau agent memanggil tool dengan kata kunci kosong atau karangan, tambahkan pada .describe() field kata kunci sebuah aturan eksplisit — misalnya "jangan tebak kata kunci; kalau pengguna tidak menyebutkannya, tanyakan dulu".
Ikhtisar
Validasi menyelesaikan masalah nyata, bukan kerapian. Contoh di bab ini:
z.string()alih-alihz.number()membuat pesanan katering 25 orang menjadi 255 porsi seharga Rp12.750.000 — tanpa satu pun error muncul.Yang divalidasi adalah bentuk data yang menyeberang dari model ke kodemu. Setelah garis itu, seluruh kode boleh berasumsi.
Bug tipe tidak merusak semuanya — 4 dari 7 operasi tetap benar. Itulah yang membuatnya lolos testing.
Model tidak salah saat mengirim
"25"; schema-nya yang memintanya. Validasi menutup celah antara apa yang kamu minta dan apa yang kodemu asumsikan.Model memilih tool berdasarkan tiga hal: pesan pengguna, instruksi agent, dan deskripsi serta schema tool. Dua yang terakhir sepenuhnya kendalimu.
descriptionyang baik menyebutkan apa yang dikembalikan, kapan dipakai, dan kapan tidak dipakai. Kalimat batas menjadi krusial saat tool bertambah banyak.Persempit
inputSchemadenganz.enum(), batasan numerik, dan.default()— setiap penyempitan menghapus satu tebakan..describe()sebaiknya memuat aturan, bukan sekadar nama — termasuk apa yang harus dilakukan saat informasi tidak tersedia.outputSchemabukan formalitas: ia membentuk jawaban akhir agent dan menjadi kontrak antar pemakai tool.toModelOutputmenyaring untuk model;transformmenyaring untuk UI dan transkrip. Kalautransformgagal, Mastra tidak menampilkan payload mentah sebagai gantinya.
Lanjut ke mana
Schema-mu sudah rapi dan tool dipanggil pada saat yang tepat. Sekarang pertanyaan yang belum terjawab: apa yang terjadi kalau tool itu gagal, dan bagaimana kalau tindakannya terlalu berisiko untuk dijalankan tanpa izin manusia — 2.3 Siklus Agent-Tool & Error.