Bug agent jarang datang sebagai jawaban yang salah. Dia datang sebagai KeyError di dalam tool, tiga retry yang gagal dengan cara sama, dan refund 0.00 yang nggak ada yang sadar seminggu.
Tambalan biasanya satu kalimat lagi di system prompt: "Selalu cek order dulu dan pakai id yang persis." Prompt itu permintaan. Build di bawah ini mengubah bagian yang penting jadi aturan: tool yang nggak bisa dipanggil pakai input sampah, output yang dicek sebelum kode kamu sentuh, batas refund yang ditegakkan di Python, dan test suite yang menjalankan seluruh loop agent tanpa satu pun panggilan API.
Pydantic AI 2.63.0 rilis 28 Oktober 2026. Dia menaruh loop agent bertipe di depan provider model apa pun, jadi batas tipenya persis di tempat bug-bug ini tinggal.
Yang kamu butuh
- Python 3.10 atau lebih baru, karena contohnya pakai
str | None - Ollama jalan lokal, atau API key untuk provider hosted
- pytest untuk bagian terakhir
Langkah 1: install, arahkan agent ke model lokal
uv venv
source .venv/bin/activate
uv pip install "pydantic-ai==2.63.0" pytest
ollama pull qwen3
Pydantic AI ngobrol ke Ollama lewat endpoint kompatibel OpenAI, jadi paket pydantic-ai lengkap sudah cukup. Kalau kamu pakai pydantic-ai-slim, tambahkan extra openai. Base URL default-nya http://localhost:11434/v1; ganti lewat OLLAMA_BASE_URL kalau Ollama ada di host lain.
Langkah 2: deskripsikan datanya sekali
Dua model Pydantic dan satu dataclass sudah melakukan sebagian besar kerja. Order itu yang dikembalikan toko, Decision itu yang wajib dihasilkan agent, dan SupportDeps itu semua yang boleh disentuh tool.
from dataclasses import dataclass, field
from datetime import date
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, Field
from pydantic_ai import Agent, ModelRetry, RunContext
class Order(BaseModel):
id: str
total: Decimal
delivered_on: date | None = None
refunded: bool = False
class Decision(BaseModel):
action: Literal["refund", "replace", "deny"]
amount: Decimal = Field(default=Decimal("0"), ge=0)
reason: str = Field(max_length=200)
@dataclass
class SupportDeps:
orders: dict[str, Order]
today: date
refund_cap: Decimal = Decimal("150.00")
calls: list[str] = field(default_factory=list)
support_agent = Agent(
"ollama:qwen3",
deps_type=SupportDeps,
output_type=Decision,
instructions=(
"You triage refund requests for an online store. "
"Always look up the order before deciding. "
"Never invent an order id. Keep reason under 200 characters."
),
)
Argumen deps_type perlu catatan: yang kamu kirim tipenya, bukan instance. Nggak ada yang dibangun saat import dan nggak ada yang disimpan global. Instance-nya masuk per run di run_sync(..., deps=deps), dan itu yang bikin test di langkah 6 pendek. Kamu mengganti dataclass, bukan modul.
Langkah 3: kasih tool cara untuk menolak
@support_agent.tool
def lookup_order(ctx: RunContext[SupportDeps], order_id: str) -> str:
"""Fetch one order by its id, for example ORD-1042."""
ctx.deps.calls.append(order_id)
order = ctx.deps.orders.get(order_id)
if order is None:
raise ModelRetry(
f"No order with id {order_id!r}. Ask the customer for the id printed on the receipt."
)
return order.model_dump_json()
Pakai @agent.tool kalau fungsinya butuh ctx, dan @agent.tool_plain kalau nggak. Melempar ModelRetry mengirim pesanmu balik ke model sebagai retry prompt dan membiarkannya coba lagi; run-nya nggak ikut mati. Pesannya sendiri yang jadi inti, karena itu satu-satunya feedback yang diterima model:
No order with id 'ORD-9999'. Ask the customer for the id printed on the receipt.
Satu instruksi spesifik seperti itu menggeser perilaku lebih jauh daripada kalimat "hati-hati" sepanjang apa pun di system prompt. Retry ada budgetnya, dan @support_agent.tool(retries=2) menaikkan budget buat tool yang memang butuh percobaan kedua.
Langkah 4: validasi keputusannya, bukan suasananya
output_type=Decision artinya run nggak bisa selesai pakai prosa. Model menghasilkan tool call yang argumennya wajib memenuhi Decision, dan Pydantic menolak yang lain sebelum kode kamu jalan.
Validator adalah tempat logika bisnis, karena logika bisnis bukan prompt:
@support_agent.output_validator
def enforce_refund_cap(ctx: RunContext[SupportDeps], decision: Decision) -> Decision:
if decision.action == "refund" and decision.amount > ctx.deps.refund_cap:
raise ModelRetry(
f"Refunds above {ctx.deps.refund_cap} need a human. "
"Use action='deny' and say the request was escalated."
)
if decision.action != "refund" and decision.amount != 0:
raise ModelRetry("amount must be 0 unless action is refund.")
return decision
Perhatikan apa yang nggak dia lakukan: dia nggak minta model jujur soal batas refund. Dia membandingkan dua angka. Refund 900.00 dengan cap 150.00 melempar ModelRetry, model diberi tahu untuk menolak dan eskalasi, lalu percobaan kedua divalidasi dengan cara yang sama.
Decimal dan bukan float itu pilihan sadar. Uang di float biner berubah jadi 24.900000000000002 di kolom alasan enam bulan kemudian.
Langkah 5: batasi loop sebelum dia membatasi kartumu
Loop retry yang terus gagal itu tagihan. UsageLimits menghentikannya, dan capture_run_messages menyimpan kerja yang sudah terjadi.
from pydantic_ai import UsageLimits, capture_run_messages
from pydantic_ai.exceptions import UsageLimitExceeded
def handle(message: str, deps: SupportDeps) -> Decision | None:
with capture_run_messages() as messages:
try:
result = support_agent.run_sync(
message, deps=deps, usage_limits=UsageLimits(request_limit=4)
)
return result.output
except UsageLimitExceeded as exc:
print(f"stopped: {exc}")
print(f"kept {len(messages)} messages for the next attempt")
return None
Message yang tertangkap itu riwayat run yang normal. Kirim balik sebagai message_history dan run berikutnya lanjut dari langkah terakhir yang selesai, bukan mengulang tool yang sudah punya efek samping. Di alur support, artinya nggak ada refund kedua.
Langkah 6: tes loop-nya tanpa panggilan network
Di sini rasanya berubah kalau kamu maintain agent ini. Nggak ada key, nggak ada latency, nggak ada assertion yang jebol cuma karena model ganti kalimat.
from pydantic_ai import ModelResponse, ToolCallPart, capture_run_messages, models
from pydantic_ai.models.function import AgentInfo, FunctionModel
from pydantic_ai.models.test import TestModel
models.ALLOW_MODEL_REQUESTS = False
Baris itu jebakan tikus. Jalur kode mana pun yang mencoba menghubungi model asli akan melempar error, bukan menghabiskan uang, termasuk jalur yang lupa kamu override.
TestModel menutup happy path. Dua detail penting. call_tools=[] menghentikannya memanggil semua tool pakai argumen karangan, yang kalau nggak dimatikan bakal jadi tumpukan retry karena tool-nya menolak id asal. custom_output_args memasukkan argumen persis ke output tool, jadi validator benar-benar jalan.
def test_happy_path_without_network(deps: SupportDeps) -> None:
with support_agent.override(
model=TestModel(
call_tools=[],
custom_output_args={"action": "deny", "amount": "0", "reason": "Out of policy."},
)
):
result = support_agent.run_sync("Can I get a refund on ORD-1042?", deps=deps)
assert result.output.action == "deny"
assert result.usage.requests == 1
FunctionModel menutup jalur yang benar-benar rusak di production, di mana model salah dulu baru benar:
def scripted_model(messages, info: AgentInfo) -> ModelResponse:
if len(messages) == 1:
return ModelResponse(parts=[ToolCallPart("lookup_order", {"order_id": "ORD-9999"})])
retry_seen = any(
getattr(part, "part_kind", "") == "retry-prompt" for msg in messages for part in msg.parts
)
if retry_seen and not any(
getattr(part, "tool_name", None) == "lookup_order"
and getattr(part, "part_kind", "") == "tool-return"
for msg in messages
for part in msg.parts
):
return ModelResponse(parts=[ToolCallPart("lookup_order", {"order_id": "ORD-1042"})])
return ModelResponse(
parts=[
ToolCallPart(
info.output_tools[0].name,
{"action": "refund", "amount": "24.90", "reason": "Item arrived damaged."},
)
]
)
def test_tool_retry_then_success(deps: SupportDeps) -> None:
with capture_run_messages() as messages:
with support_agent.override(model=FunctionModel(scripted_model)):
result = support_agent.run_sync("Refund please, order ORD-9999.", deps=deps)
assert result.output.action == "refund"
assert deps.calls == ["ORD-9999", "ORD-1042"]
retries = [
part
for msg in messages
for part in msg.parts
if getattr(part, "part_kind", "") == "retry-prompt"
]
assert len(retries) == 1
info.output_tools[0].name itu cara model scripted menyelesaikan run terstruktur. Dengan output_type aktif, jawaban akhir datang sebagai tool call, bukan teks. Dan capture_run_messages bikin kamu bisa memastikan tepat satu retry prompt muncul, yang justru assertion penting di sini: tool menolak id-nya sekali, model membetulkan sekali.
Jalankan pytest -q. Hasilnya sama di laptop dan di CI, dan job CI-nya nggak butuh secret.
Langkah 7: ganti model tanpa menyentuh logikanya
Pilihan model tinggal di satu string. Ganti ollama:qwen3 jadi openai:, anthropic:, google:, atau prefix lain yang didukung, dan tool, validator, limit, serta test-nya tetap sama.
Buat model lokal ada satu upgrade yang layak diketahui. Ollama 0.5.0 ke atas menegakkan json_schema lewat grammar-constrained decoder llama.cpp, dan Pydantic AI mengeksposnya sebagai NativeOutput:
from pydantic_ai.output import NativeOutput
strict_agent = Agent(
"ollama:qwen3",
deps_type=SupportDeps,
output_type=NativeOutput(Decision),
instructions="You triage refund requests for an online store.",
)
Bedanya di titik constraint-nya menggigit. Tool output minta model memanggil fungsi dengan argumen valid lalu divalidasi setelahnya. NativeOutput membatasi generasinya, jadi JSON rusak berhenti jadi retry yang kamu bayar.
Kapan alat ini bukan pilihan yang tepat
- LangGraph kalau alur kontrolnya adalah produknya: cabang, checkpoint, langkah approval sebagai node kelas satu. Kalau kamu memang sedang menggambar graph, modelkan langsung.
- OpenAI Agents SDK kalau kamu sudah commit ke OpenAI dan handoff antar agent adalah primitif yang kamu butuh.
- Claude Agent SDK kalau pekerjaannya lebih dekat ke agent coding atau file editing dengan sandbox yang sudah disediakan.
- Pydantic AI kalau agent-nya cuma satu komponen di dalam service Python bertipe dan kamu mau test-nya mirip isi repo lainnya.
Yang nggak diceritakan unit test
Unit test membuktikan kode di sekitar model. Dia nggak bilang apa-apa soal kualitas jawaban. Itu evals, harness sendiri dan budget sendiri, dan mencampur keduanya bikin tim menghabiskan sore mendebug prompt padahal bug-nya strip() yang lupa dipanggil.
Langkah berikutnya yang langsung kerasa: taruh pytest di CI dengan ALLOW_MODEL_REQUESTS = False, supaya override yang lupa ditulis bikin build gagal, bukan memukul endpoint berbayar jam 2 pagi. Setelah itu tulis lima eval case untuk keputusan yang penting, mulai dari refund di atas cap, karena itu kasus yang validatornya nggak boleh salah.
Referensi
- Agents,
deps_type,output_type,UsageLimits, dancapture_run_messages: https://pydantic.dev/docs/ai/core-concepts/agent/ - Unit testing dengan
TestModel,FunctionModel,Agent.override, danALLOW_MODEL_REQUESTS: https://pydantic.dev/docs/ai/guides/testing/ - Konfigurasi Ollama dan enforcement schema
NativeOutput: https://pydantic.dev/docs/ai/models/ollama/ - Versi paket 2.63.0, rilis 28 Oktober 2026: https://pypi.org/project/pydantic-ai/