Skip to content

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-karyawanTool terpisah per kebutuhan
Jumlah tool12+
Risiko salah pilih toolNolAda, harus dikelola deskripsi
Ukuran keluaranSemua data ikut, borosHanya yang diminta
Izin per dataSulit — satu tool, satu tingkat aksesMudah, per tool
Kejelasan di traceBuram: "ambil-data" sajaJelas 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".

PendekatanEfektivitasOngkos
Andalkan nama tool sajaRendahNol
Tambah aturan di instructions agentSedangInstruksi membengkak
Kalimat batas di description tiap toolTinggiNol

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 exceptionKembalikan sebagai data
Yang dialami penggunaPermintaan gagal / pesan error teknisKalimat yang bisa dimengerti
Agent bisa menjelaskan?Tidak — ia tidak pernah tahuYa
Agent bisa menyarankan perbaikan?TidakYa, misalnya "periksa format NIK"
Kerumitan kodeLebih sedikitPerlu 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.json

Aturan 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:

  1. Tool dipilih dengan tepat — dua pertanyaan mirip, dua tool berbeda. Kalimat batas di deskripsi bekerja.
  2. Kegagalan ditangani anggun — NIK K-999 tidak menghasilkan error, dan tidak ada angka karangan.
  3. Dua tool dipanggil dalam satu giliran untuk pertanyaan gabungan.
  4. 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

SituasiKenapa gagalKe mana
NIK harus diambil dari sesi login, bukan diketik penggunaPengguna bisa mengetik NIK orang lain10.10 Multi-tenant
"Kalau begitu ajukan cuti 3 hari"Mengubah keadaan — butuh persetujuan10.9 Approval
Percakapan berlanjut tanpa mengulang NIKTanpa memory, tiap pesan berdiri sendiri10.3 WhatsApp
Tool jadi 10+ dan agent mulai sering salah pilihTerlalu banyak pilihan untuk satu agent10.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, bukan cek-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
  • [ ] execute tiap 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.

Materi belajar mandiri. Bukan dokumentasi resmi Mastra.