Tampilan
10.2 Asisten Data Eksternal
Topologi: A — Agent tunggal Materi yang dipakai: 2.1, 2.2, 2.3Prasyarat: 10.1
Masalahnya
Bot FAQ dari kasus sebelumnya sukses — sampai seseorang bertanya "sisa cuti saya berapa?"
Itu pertanyaan yang berbeda jenis. Bukan tentang kebijakan yang berlaku untuk semua orang, melainkan data yang khas untuk satu orang. Tidak ada cara menaruhnya di instructions: datanya berubah tiap hari dan berbeda per karyawan.
Sekaligus muncul kebutuhan kedua yang mirip tapi berbeda: "reimburse saya sudah cair belum?"
Dua kebutuhan yang berdekatan ini justru menciptakan masalah baru — agent harus bisa membedakan keduanya dengan tepat.
Keputusan arsitektur
Keputusan 1 — Satu tool serbaguna atau beberapa tool spesifik?
Satu tool ambil-data-karyawan | Tool terpisah per kebutuhan | |
|---|---|---|
| Jumlah tool | 1 | 2+ |
| Risiko salah pilih tool | Nol | Ada, harus dikelola deskripsi |
| Ukuran keluaran | Semua data ikut, boros | Hanya yang diminta |
| Izin per data | Sulit — satu tool, satu tingkat akses | Mudah, per tool |
| Kejelasan di trace | Buram: "ambil-data" saja | Jelas tool mana dipanggil |
Pilihan: tool terpisah.
Alasan yang menentukan bukan kerapian, melainkan izin. Data cuti dan data reimbursement punya aturan akses berbeda — atasan boleh melihat sisa cuti timnya, tapi tidak nominal reimbursement mereka. Dengan satu tool serbaguna, pembedaan itu harus dilakukan di dalam execute dengan percabangan yang mudah salah.
Ongkosnya: agent sekarang bisa memilih tool yang salah. Itu diselesaikan di keputusan berikutnya.
Keputusan 2 — Bagaimana mencegah tool tertukar?
Dua tool ini berbahaya karena mirip: sama-sama "ambil data karyawan berdasarkan NIK".
| Pendekatan | Efektivitas | Ongkos |
|---|---|---|
| Andalkan nama tool saja | Rendah | Nol |
Tambah aturan di instructions agent | Sedang | Instruksi membengkak |
Kalimat batas di description tiap tool | Tinggi | Nol |
Pilihan: kalimat batas di deskripsi tool.
Polanya dari 2.2 — sebutkan kapan dipakai dan kapan tidak:
ts
description:
'Ambil SISA KUOTA CUTI seorang karyawan berdasarkan NIK. ' +
'Pakai untuk pertanyaan "berapa sisa cuti saya". ' +
'BUKAN untuk status pengajuan reimbursement.',Kalimat ketiga itu yang bekerja. Ia menyebut saudaranya secara eksplisit, sehingga model punya dasar membedakan — bukan sekadar menebak dari kemiripan kata.
Kenapa di deskripsi tool, bukan di instruksi agent
Deskripsi tool ikut dikirim hanya saat tool itu relevan dipertimbangkan, dan letaknya persis di sebelah schema-nya. Aturan yang ditaruh di instruksi agent terpisah jauh dari tool yang diaturnya — dan ikut dibayar di setiap panggilan, termasuk yang tidak menyangkut tool sama sekali.
Keputusan 3 — Bagaimana menangani NIK yang tidak ada?
| Lempar exception | Kembalikan sebagai data | |
|---|---|---|
| Yang dialami pengguna | Permintaan gagal / pesan error teknis | Kalimat yang bisa dimengerti |
| Agent bisa menjelaskan? | Tidak — ia tidak pernah tahu | Ya |
| Agent bisa menyarankan perbaikan? | Tidak | Ya, misalnya "periksa format NIK" |
| Kerumitan kode | Lebih sedikit | Perlu field status di schema |
Pilihan: kegagalan sebagai data, pola dari 2.3.
Bentuknya: outputSchema punya field status bertipe enum, dan field data bertipe nullable.
Struktur direktori
text
asisten-hr/
├── src/
│ ├── domain/
│ │ ├── kebijakan.ts ← dari kasus 10.1
│ │ └── karyawan.ts ← BARU: akses data karyawan
│ └── mastra/
│ ├── agents/
│ │ └── hr-agent.ts
│ ├── tools/
│ │ ├── cek-sisa-cuti.ts ← satu berkas per tool
│ │ └── cek-status-reimburse.ts
│ └── index.ts
├── .env
└── package.jsonAturan satu berkas per tool mulai terbayar di sini. Saat tool bertambah jadi delapan, mencari "tool mana yang mengurus reimbursement" jadi soal membaca nama berkas, bukan menggulir satu berkas panjang.
Alur
Perhatikan: model dipanggil tiga kali untuk satu pertanyaan — dua kali memutuskan tool, sekali menyusun jawaban. Ini konsekuensi loop yang dibahas di 2.3, dan alasan kenapa pertanyaan gabungan lebih mahal dari yang terlihat.
Implementasi
src/domain/karyawan.ts
ts
// Tidak ada impor Mastra. Bisa diuji dengan unit test biasa.
export type HasilCuti =
| { status: 'ok'; nama: string; sisaCuti: number; cutiTerpakai: number }
| { status: 'karyawan-tidak-ditemukan' }
export type Reimburse = {
id: string
jumlah: number
status: string
tgl: string
}
export type HasilReimburse =
| { status: 'ok'; daftar: Reimburse[] }
| { status: 'karyawan-tidak-ditemukan' }
// Di produksi ini query database. Untuk contoh, data dalam memori.
const DB: Record<string, { nama: string; sisaCuti: number; cutiTerpakai: number }> = {
'K-001': { nama: 'Sari', sisaCuti: 7, cutiTerpakai: 5 },
'K-002': { nama: 'Budi', sisaCuti: 0, cutiTerpakai: 12 },
}
const REIMBURSE: Record<string, Reimburse[]> = {
'K-001': [{ id: 'R-88', jumlah: 450_000, status: 'diproses', tgl: '2026-08-30' }],
'K-002': [],
}
export function ambilSisaCuti(nik: string): HasilCuti {
const k = DB[nik]
if (!k) return { status: 'karyawan-tidak-ditemukan' }
return { status: 'ok', nama: k.nama, sisaCuti: k.sisaCuti, cutiTerpakai: k.cutiTerpakai }
}
export function ambilReimburse(nik: string): HasilReimburse {
if (!DB[nik]) return { status: 'karyawan-tidak-ditemukan' }
return { status: 'ok', daftar: REIMBURSE[nik] ?? [] }
}Perhatikan tipe kembaliannya adalah discriminated union. Kegagalan sudah menjadi bagian dari kontrak di lapisan domain, bukan ditambahkan belakangan di tool.
src/mastra/tools/cek-sisa-cuti.ts
ts
import { createTool } from '@mastra/core/tools'
import { z } from 'zod'
import { ambilSisaCuti } from '../../domain/karyawan.ts'
export const cekSisaCuti = createTool({
id: 'cek-sisa-cuti',
description:
'Ambil SISA KUOTA CUTI seorang karyawan berdasarkan NIK. ' +
'Pakai untuk pertanyaan "berapa sisa cuti saya". ' +
'BUKAN untuk status pengajuan reimbursement.',
inputSchema: z.object({
nik: z.string().describe('NIK karyawan, format K-XXX'),
}),
outputSchema: z.object({
status: z.enum(['ok', 'karyawan-tidak-ditemukan']),
nama: z.string().nullable(),
sisaCuti: z.number().nullable(),
cutiTerpakai: z.number().nullable(),
}),
execute: async ({ nik }) => {
const h = ambilSisaCuti(nik)
return h.status === 'ok'
? { status: 'ok' as const, nama: h.nama, sisaCuti: h.sisaCuti, cutiTerpakai: h.cutiTerpakai }
: { status: 'karyawan-tidak-ditemukan' as const, nama: null, sisaCuti: null, cutiTerpakai: null }
},
})execute di sini 7 baris dan tidak memuat satu pun aturan bisnis — ia cuma memanggil domain dan membentuk hasilnya. Ini bentuk "tool tipis, domain tebal" yang disebut di pengantar bagian ini.
src/mastra/tools/cek-status-reimburse.ts
ts
import { createTool } from '@mastra/core/tools'
import { z } from 'zod'
import { ambilReimburse } from '../../domain/karyawan.ts'
export const cekStatusReimburse = createTool({
id: 'cek-status-reimburse',
description:
'Ambil STATUS PENGAJUAN REIMBURSEMENT seorang karyawan berdasarkan NIK. ' +
'Pakai untuk pertanyaan "reimburse saya sudah cair belum". ' +
'BUKAN untuk kuota cuti.',
inputSchema: z.object({
nik: z.string().describe('NIK karyawan, format K-XXX'),
}),
outputSchema: z.object({
status: z.enum(['ok', 'karyawan-tidak-ditemukan']),
daftar: z
.array(
z.object({
id: z.string(),
jumlah: z.number(),
status: z.string(),
tgl: z.string(),
}),
)
.nullable(),
}),
execute: async ({ nik }) => {
const h = ambilReimburse(nik)
return h.status === 'ok'
? { status: 'ok' as const, daftar: h.daftar }
: { status: 'karyawan-tidak-ditemukan' as const, daftar: null }
},
})src/mastra/agents/hr-agent.ts
ts
import { Agent } from '@mastra/core/agent'
import { cekSisaCuti } from '../tools/cek-sisa-cuti.ts'
import { cekStatusReimburse } from '../tools/cek-status-reimburse.ts'
export const hrAgent = new Agent({
id: 'hr-agent',
name: 'HR Agent',
instructions: `Kamu asisten HR.
Pakai cekSisaCuti untuk pertanyaan kuota cuti.
Pakai cekStatusReimburse untuk pertanyaan status reimbursement.
Kalau tool mengembalikan status "karyawan-tidak-ditemukan", sampaikan dengan
sopan dan minta pengguna memeriksa NIK-nya. Jangan mengarang angka.`,
model: 'openai/gpt-5-mini',
tools: { cekSisaCuti, cekStatusReimburse },
})Satu baris instruksi per tool — pola dari 2.1. Plus satu aturan tentang cara menangani kegagalan.
Hasil nyata
Dijalankan sungguhan. Baris [tool: ...] adalah console.log di dalam execute, jadi ia membuktikan tool benar-benar jalan.
text
> sisa cuti saya berapa? NIK K-001
[tool: cek-sisa-cuti(K-001)]
Untuk NIK K-001 (Sari), sisa cuti Anda saat ini adalah 7 hari.
Total cuti terpakai: 5 hari.
> reimburse saya sudah cair belum? NIK K-001
[tool: cek-status-reimburse(K-001)]
Belum cair. Detail pengajuan Anda:
- ID: R-88
- Jumlah: Rp450.000
- Tanggal pengajuan: 30-08-2026
- Status: diproses
> sisa cuti NIK K-999 berapa?
[tool: cek-sisa-cuti(K-999)]
Maaf, data untuk NIK K-999 tidak ditemukan. Mohon periksa kembali NIK Anda
(format: K-XXX) dan coba lagi.
> NIK K-002: sisa cuti dan status reimburse saya bagaimana?
[tool: cek-sisa-cuti(K-002)]
[tool: cek-status-reimburse(K-002)]
Berikut status untuk NIK K-002 (Budi):
- Sisa cuti: 0 hari (cuti terpakai: 12 hari).
- Status pengajuan reimbursement: tidak ada pengajuan yang tercatat saat ini.Empat hal yang terbukti dari transkrip ini:
- Tool dipilih dengan tepat — dua pertanyaan mirip, dua tool berbeda. Kalimat batas di deskripsi bekerja.
- Kegagalan ditangani anggun — NIK K-999 tidak menghasilkan error, dan tidak ada angka karangan.
- Dua tool dipanggil dalam satu giliran untuk pertanyaan gabungan.
- Daftar kosong dibedakan dari tidak ditemukan — K-002 punya
daftar: [], dan agent menyampaikannya sebagai "tidak ada pengajuan tercatat", bukan sebagai error.
Poin keempat sering luput saat merancang schema. daftar: [] dan status: 'karyawan-tidak-ditemukan' adalah dua keadaan yang sangat berbeda, dan schema ini bisa membedakannya.
Best practice yang dipakai di sini
1. Discriminated union di domain. HasilCuti adalah union dari sukses dan gagal. Compiler memaksa kode pemanggil menangani keduanya — kamu tidak bisa lupa.
2. Tool tidak memuat aturan bisnis. Kalau besok aturan berubah menjadi "karyawan kontrak tidak punya kuota cuti", yang diedit domain/karyawan.ts, bukan tool.
3. Kalimat "BUKAN untuk..." di tiap deskripsi. Ini yang paling murah dan paling efektif untuk tool yang berdekatan.
4. nullable(), bukan optional(). Field data bertipe .nullable() sehingga bentuk objeknya selalu sama — model selalu melihat field yang sama, hanya isinya null saat gagal. Dengan .optional(), field-nya kadang hilang sama sekali, dan itu lebih sulit ditangani secara konsisten.
Kapan pola ini salah
| Situasi | Kenapa gagal | Ke mana |
|---|---|---|
| NIK harus diambil dari sesi login, bukan diketik pengguna | Pengguna bisa mengetik NIK orang lain | 10.10 Multi-tenant |
| "Kalau begitu ajukan cuti 3 hari" | Mengubah keadaan — butuh persetujuan | 10.9 Approval |
| Percakapan berlanjut tanpa mengulang NIK | Tanpa memory, tiap pesan berdiri sendiri | 10.3 WhatsApp |
| Tool jadi 10+ dan agent mulai sering salah pilih | Terlalu banyak pilihan untuk satu agent | 10.12 Subagent |
Baris pertama adalah lubang keamanan nyata di kode ini. NIK datang dari teks yang diketik pengguna, artinya siapa pun bisa melihat data siapa pun. Untuk demo internal itu bisa diterima; untuk sistem sungguhan tidak. Kasus 10.10 memperbaikinya.
Coba sendiri
Tambahkan tool ketiga yang sengaja berdekatan dengan yang sudah ada: cek-riwayat-cuti — mengembalikan daftar tanggal cuti yang sudah diambil.
Sekarang ada dua tool yang sama-sama tentang cuti.
Selesai kalau
- [ ] "sisa cuti saya" memanggil
cek-sisa-cuti, bukancek-riwayat-cuti - [ ] "kapan saja saya cuti tahun ini" memanggil
cek-riwayat-cuti - [ ] Uji 5 pertanyaan di perbatasan kedua tool — semuanya memilih tepat
- [ ] NIK tak dikenal tetap ditangani anggun di ketiga tool
- [ ]
executetiap tool tidak lebih dari 10 baris
Kalau macet
Agent memanggil tool yang salah? Baca kedua deskripsi berurutan seolah kamu model. Kalau kamu sendiri ragu, tambahkan kalimat "BUKAN untuk..." yang menyebut tool saudaranya secara eksplisit.
Agent memanggil kedua tool cuti sekaligus padahal cuma perlu satu? Itu bukan selalu salah — tapi kalau boros, pertegas di deskripsi bahwa keduanya saling menggantikan, bukan saling melengkapi.
Lanjut ke mana
Agent ini masih pelupa: tiap pertanyaan harus menyertakan NIK lagi. Kasus berikutnya memasang memory sekaligus menaruhnya di balik webhook WhatsApp — 10.3 Chatbot WhatsApp CS.