Backend
Durable AI Agent dengan Temporal: Tahan Crash dan Penantian
Oktober 202612 menit baca

Durable AI agent adalah agent yang progresnya disimpan di luar proses yang menjalankannya, sehingga crash, restart, atau penantian panjang tetap lanjut di langkah yang sama, bukan mulai dari awal. Dengan Temporal, loop agent berjalan di workflow dan setiap model call serta tool call berjalan sebagai activity yang hasilnya dicatat di event history. Saat pemulihan, langkah yang sudah selesai mengembalikan hasil yang tercatat tanpa dijalankan lagi.
Install package temporalio-openai-agents, daftarkan OpenAIAgentsPlugin di Temporal Client, lalu panggil Runner.run di dalam workflow seperti biasa. Plugin mengubah setiap model call menjadi activity. Tool yang melakukan I/O harus dibungkus dengan activity_as_tool, dan fungsi activity-nya didaftarkan di worker.
Di Temporal, workflow menunggu dengan workflow.wait_condition terhadap nilai yang diisi oleh signal atau update handler, dengan timeout opsional misalnya tiga hari. Penantian itu adalah timer di sisi server, jadi tidak ada worker yang menyimpannya di memori dan ia tetap selamat saat restart maupun deploy. Begitu signal approval datang, workflow melanjutkan dari titik itu.
Temporal me-replay workflow yang sedang berjalan dengan kode baru, jadi perubahan urutan langkah bisa memicu error non-determinism. Lindungi langkah baru dengan workflow.patched, atau pakai Worker Versioning untuk mengunci eksekusi ke kode yang memulainya. Replay test terhadap history yang tercatat menemukan perubahan yang butuh patch sebelum dirilis.
Temporal cocok untuk agent yang menjadi satu langkah dalam proses bisnis lebih besar yang butuh timer, retry, dan audit history, karena Temporal mencatat setiap activity dan signal. Checkpointer LangGraph menyimpan state graph per super-step dan lebih ringan dioperasikan jika agent sudah berjalan sebagai graph. Untuk penulisan ke ERP di LangGraph, pakai durability mode sync agar setiap checkpoint ditulis sebelum langkah berikutnya.

Ringkasan Utama
Durable AI agent di Temporal menaruh loop agent di workflow yang deterministik dan memindahkan setiap model call serta tool call ke activity, yang hasilnya dicatat di event history. Worker yang crash memutar ulang history itu tanpa mengulang pekerjaan, approval bisa ditunggu berhari-hari dengan durable timer, dan patch marker membuat agent yang sedang berjalan tetap selamat saat deploy.
Bayangkan agent purchase request di sebuah ERP. Agent membaca permintaan, mengecek sisa budget cost centre, menulis ringkasan untuk manajer departemen, lalu menunggu keputusan. Manajernya sedang di lokasi supplier sampai Kamis. Selasa malam tim men-deploy rilis baru, pod worker restart, dan await di memori yang menyimpan posisi agent ikut hilang. Tidak ada yang sadar sampai requester bertanya kenapa PO-nya tidak pernah dibuat.
Itulah masalah yang diselesaikan durable AI agent dengan Temporal: posisi agent disimpan oleh Temporal server, bukan di sebuah proses, sehingga crash, restart, atau penantian tiga hari tetap lanjut di langkah yang sama. Artikel ini menjelaskan pembagian workflow dan activity, mengimplementasikan approval pembelian dengan plugin temporalio-openai-agents untuk OpenAI Agents SDK, menunjukkan cara merilis versi baru saat approval masih terbuka, dan membandingkan pekerjaan yang sama di Pydantic AI dan LangGraph. Semua nama API di bawah diambil dari dokumentasi resmi Temporal, Pydantic, OpenAI, dan LangChain yang tercantum di akhir.
Satu run agent adalah loop model call dan tool call, dan setiap langkah bergantung pada hasil langkah sebelumnya. Kalau loop itu hidup di coroutine Python, umurnya sama dengan umur proses. Jika worker crash setelah tool call ketiga, retry yang naif mulai dari awal: memanggil model lagi, mendapat rencana yang sedikit berbeda, membaca ERP lagi, dan bisa menulis dua kali. Jika loop harus menunggu manusia, prosesnya harus tetap hidup selama orang itu butuh waktu, dan tidak ada yang bisa menjanjikan itu.
Jaminan Temporal lebih sempit tetapi lebih berguna daripada sekadar retry. Selama workflow berjalan, server mencatat setiap activity yang dijadwalkan beserta hasilnya di event history workflow. Saat sebuah worker mati, worker lain memutar ulang kode workflow terhadap history itu: model call dan tool call yang sudah selesai mengembalikan hasil yang tercatat tanpa dijalankan lagi, lalu eksekusi berlanjut dari langkah pertama yang belum punya hasil. Penantian berupa timer atau kondisi di server, jadi workflow yang diam tiga hari sama sekali tidak memakai worker.
Semuanya bertumpu pada satu aturan dari dokumentasi Temporal: kode workflow harus deterministik karena dieksekusi ulang saat replay, sedangkan activity boleh melakukan I/O apa pun tetapi diulang dari awal jika gagal di tengah jalan. Untuk agent, aturan ini memilah setiap baris kode ke salah satu dari dua tempat.
| Bagian agent | Dijalankan di | Alasannya |
|---|---|---|
| Model call | Activity, otomatis | Panggilan jaringan dengan jawaban yang tidak deterministik. Plugin mengarahkan setiap model request dari Runner.run lewat activity, sehingga replay membaca respons yang tercatat tanpa bertanya ke model lagi. |
| Tool baca ERP | Activity, lewat activity_as_tool | I/O ke database atau HTTP. Function tool biasa akan berjalan di dalam workflow, tempat I/O tidak diizinkan. |
| Tulis ke ERP, misalnya konversi PR menjadi PO | Activity, dipanggil oleh workflow, bukan oleh model | Side effect yang harus terjadi tepat sekali, jadi butuh idempotency key dan sebaiknya menjadi keputusan workflow, bukan keputusan model. |
| Komputasi murni, misalnya total pajak | Workflow, sebagai function tool | Deterministik dan tanpa I/O, jadi aman dijalankan ulang di setiap replay. |
| Menunggu manajer | Workflow, lewat signal dan wait_condition | Penantian dan timeout-nya hidup di Temporal server, sehingga worker boleh restart atau di-deploy ulang selama menunggu. |
| Jam, angka acak, environment | API workflow atau sebuah activity | Membaca datetime.now() atau environment variable di kode workflow memberi jawaban berbeda saat replay, dan justru non-determinisme seperti itu yang tidak bisa ditoleransi replay. |
Baris ketiga adalah keputusan desain paling penting di ERP. Model boleh membaca dan meringkas, tetapi langkah yang membuat purchase order dipanggil oleh workflow setelah seseorang menyetujuinya. Dengan begitu aksi yang tidak bisa dibatalkan tetap di luar kendali model, dan ada satu tempat yang jelas untuk idempotency key.
Integrasinya sekarang dirilis sebagai package tersendiri. Dokumentasi Temporal menyebut versi 1.0.0 memindahkannya keluar dari Python SDK: extra temporalio[openai-agents] menjadi distribusi temporalio-openai-agents, dan import pindah dari temporalio.contrib.openai_agents ke temporalio.openai_agents. Package ini butuh Python 3.10 ke atas dan Temporal Python SDK 1.33.0 ke atas. Mulai dari activity, yaitu activity Temporal biasa tanpa kode agent di dalamnya.
# uv add temporalio-openai-agents
# (1.0.0 moved the plugin out of temporalio.contrib.openai_agents)
# activities.py: everything that touches the network lives here
from dataclasses import dataclass
from temporalio import activity
@dataclass
class PurchaseRequest:
pr_number: str
requester: str
total_idr: int
cost_center: str
@activity.defn
async def get_purchase_request(pr_number: str) -> PurchaseRequest:
"""Read one purchase request from the ERP. Read-only."""
return await erp.fetch_pr(pr_number)
@activity.defn
async def get_budget_remaining(cost_center: str) -> int:
"""Remaining budget for a cost centre this period, in rupiah."""
return await erp.budget_remaining(cost_center)
@activity.defn
async def notify_approver(pr_number: str, summary: str) -> None:
await chat.send_to_manager(pr_number, summary)
@activity.defn
async def convert_pr_to_po(pr_number: str, approver: str) -> str:
# A retried activity runs again from the top, so the ERP call takes
# the PR number as its idempotency key and returns the existing PO.
return await erp.create_po_from_pr(
pr_number, approved_by=approver, idempotency_key=pr_number
)Workflow memegang agent. Di dalamnya Anda menulis kode OpenAI Agents SDK biasa, karena plugin mengarahkan Runner.run sehingga setiap model call menjadi activity; tidak ada runner khusus Temporal. Tool yang melakukan I/O dibungkus dengan activity_as_tool, yang mengekspos activity ke model sebagai tool dan menjadwalkan activity itu setiap kali model memanggilnya.
# workflows.py: deterministic orchestration, no I/O
import asyncio
from dataclasses import dataclass
from datetime import timedelta
from temporalio import workflow
from temporalio.openai_agents.workflow import activity_as_tool
with workflow.unsafe.imports_passed_through():
from agents import Agent, Runner
from activities import (
convert_pr_to_po,
get_budget_remaining,
get_purchase_request,
notify_approver,
)
@dataclass
class Decision:
approver: str
approved: bool
note: str = ""
APPROVAL_WINDOW = timedelta(days=3)
@workflow.defn
class PurchaseApprovalWorkflow:
def __init__(self) -> None:
self.decision: Decision | None = None
@workflow.signal
def decide(self, decision: Decision) -> None:
self.decision = decision
@workflow.query
def status(self) -> str:
return "decided" if self.decision else "waiting for approver"
@workflow.run
async def run(self, pr_number: str) -> str:
reviewer = Agent(
name="pr-reviewer",
instructions=(
"Summarise the purchase request for a manager in Bahasa Indonesia: "
"total, cost centre, and whether budget remains. "
"Do not recommend approval or rejection."
),
tools=[
activity_as_tool(get_purchase_request,
start_to_close_timeout=timedelta(seconds=15)),
activity_as_tool(get_budget_remaining,
start_to_close_timeout=timedelta(seconds=15)),
],
)
# Ordinary Agents SDK code. The plugin turns every model call into an
# activity, and activity_as_tool does the same for each tool call, so
# a replay after a crash reads their results from history.
review = await Runner.run(reviewer, input=f"Review {pr_number}")
await workflow.execute_activity(
notify_approver,
args=[pr_number, review.final_output],
start_to_close_timeout=timedelta(seconds=30),
)
try:
# A durable timer on the Temporal server. No worker holds this
# wait in memory, so it survives restarts and deploys.
await workflow.wait_condition(
lambda: self.decision is not None, timeout=APPROVAL_WINDOW
)
except asyncio.TimeoutError:
return f"{pr_number}: no decision in {APPROVAL_WINDOW.days} days, escalated"
if not self.decision.approved:
return f"{pr_number}: rejected by {self.decision.approver}"
po_number = await workflow.execute_activity(
convert_pr_to_po,
args=[pr_number, self.decision.approver],
start_to_close_timeout=timedelta(seconds=60),
)
return f"{pr_number}: approved, {po_number} created"Tiga detail menopang durability-nya. Pembacaan oleh agent lewat activity, jadi crash setelah get_budget_remaining memutar ulang nilai budget yang tercatat tanpa membacanya lagi. Penantiannya adalah wait_condition dengan timeout tiga hari, yang menjadi timer di sisi server. Dan PO dibuat oleh workflow hanya setelah signal membawa keputusan, dengan nomor PR sebagai idempotency key di ERP sehingga activity yang di-retry mengembalikan PO yang sudah dibuat.
Worker dan setiap client yang memulai atau mengirim signal ke workflow harus memakai OpenAIAgentsPlugin yang sama, karena plugin juga mengatur data converter Pydantic yang men-serialize payload di kedua sisi. ModelActivityParameters mengatur activity model: start_to_close_timeout default-nya 60 detik, dan parameter ini juga menerima retry policy, heartbeat timeout, dan task queue. activity_as_tool hanya menjelaskan cara agent memanggil activity, jadi fungsi activity tetap harus didaftarkan di worker.
# worker.py: the same plugin goes on every Client, worker and caller alike
from datetime import timedelta
from temporalio.client import Client
from temporalio.openai_agents import ModelActivityParameters, OpenAIAgentsPlugin
from temporalio.worker import Worker
PLUGIN = OpenAIAgentsPlugin(
model_params=ModelActivityParameters(start_to_close_timeout=timedelta(seconds=60))
)
client = await Client.connect("localhost:7233", plugins=[PLUGIN])
worker = Worker(
client,
task_queue="erp-approvals",
workflows=[PurchaseApprovalWorkflow],
# activity_as_tool does not register activities; list them here too.
activities=[get_purchase_request, get_budget_remaining,
notify_approver, convert_pr_to_po],
)
await worker.run()
# api.py: one workflow per PR, and the PR number is the workflow id
await client.start_workflow(
PurchaseApprovalWorkflow.run,
"PR-2026-10-0042",
id="pr-approval-PR-2026-10-0042",
task_queue="erp-approvals",
)
# Two days later, from the manager's Approve button, in any process:
handle = client.get_workflow_handle("pr-approval-PR-2026-10-0042")
await handle.signal(
PurchaseApprovalWorkflow.decide,
Decision(approver="budi.santoso", approved=True),
)Memakai nomor PR sebagai workflow id memberi approval sebuah business key. Tombol approve hanya butuh id itu untuk menemukan eksekusi yang sedang menunggu, dan query status membuat layar ERP bisa menampilkan waiting for approver tanpa menyentuh state workflow. Jika pemanggil perlu menerima hasil dari panggilan yang sama, Temporal juga punya Update, yang dipakai dokumentasi integrasi untuk giliran chat yang mengembalikan balasan agent; untuk approval yang cukup dikirim lalu ditinggal, signal sudah cukup.
Agents SDK punya alur approval sendiri, yaitu needs_approval pada tool, dan di dalam Temporal hook yang didokumentasikan untuk hosted MCP tool adalah callback on_approval_request yang berjalan di konteks workflow dan bisa menunggu signal atau update. Untuk approval ERP, pilih pola eksplisit di atas: model menyelesaikan ringkasannya, workflow menunggu, dan penulisan terjadi di luar loop model. Pola ini lebih mudah diaudit dan diuji.
Durability membawa kewajiban. Workflow yang dimulai minggu lalu akan di-replay oleh kode minggu ini, jadi setiap perubahan yang mengubah urutan langkah yang akan diambil eksekusi yang sedang berjalan akan merusak replay-nya. Panduan versioning Temporal menawarkan dua alat: patched(), yang mencatat marker di history sehingga eksekusi lama tetap di jalur lama sementara eksekusi baru mengambil jalur baru, dan Worker Versioning, yang mengunci eksekusi ke versi deployment kode worker yang memulainya.
# The new release adds a vendor blacklist check before the approval wait.
# Wrong: insert the step directly. A workflow that started last week and
# is parked in wait_condition replays a history with no such activity,
# and the worker fails that workflow task with a non-determinism error.
await workflow.execute_activity(
check_vendor_blacklist, pr_number, start_to_close_timeout=timedelta(seconds=15)
)
# Right: gate the new step behind a patch marker. Old histories take the
# old path; new executions record the marker and take the new one.
if workflow.patched("vendor-blacklist-check"):
await workflow.execute_activity(
check_vendor_blacklist, pr_number, start_to_close_timeout=timedelta(seconds=15)
)
# Once no pre-patch execution is still open, swap patched() for
# workflow.deprecate_patch("vendor-blacklist-check"), then remove it.Siklus hidup patch punya tiga langkah, dan panduannya tegas soal urutannya: deploy dengan patched(), ganti ke deprecate_patch() setelah tidak ada lagi eksekusi pra-patch yang terbuka, lalu hapus keduanya setelah history tersebut keluar dari masa retention. Untuk workflow approval dengan timeout berhari-hari, itu berarti beberapa kali deploy, bukan beberapa jam. Temporal juga menyarankan replay testing, yaitu menjalankan history yang tercatat terhadap kode baru sebelum dirilis, untuk menemukan perubahan yang butuh patch.
Nama juga bagian dari kontrak. Nama activity berasal dari nama fungsi, dan di Pydantic AI dokumentasinya menyebut nama agent dan id toolset wajib diisi saat memakai Temporal dan tidak boleh diubah setelah deploy, karena mengganti namanya merusak workflow yang masih aktif. Ganti nama tool di rilis yang sama dengan habisnya approval terbuka terakhir, jangan sebelumnya.
Pydantic AI mendukung Temporal secara native dan mengikuti pembagian yang sama: model request, tool call yang mungkin butuh I/O, dan komunikasi dengan MCP server menjadi activity, sedangkan run agent hidup di workflow. API terbarunya berupa capability. Anda memasang TemporalDurability pada Agent biasa, mendaftarkan agent di subclass PydanticAIWorkflow, dan terhubung dengan PydanticAIPlugin. Wrapper TemporalAgent yang lama sudah deprecated dan akan dihapus di v3; dokumentasinya menyebut workflow yang dimulai dengannya tetap di-replay dengan benar setelah beralih, selama nama agent, id toolset, dan key registry model tidak berubah.
# uv add "pydantic-ai[temporal]"
from datetime import timedelta
from pydantic_ai import Agent
from pydantic_ai.durable_exec.temporal import (
PydanticAIPlugin,
PydanticAIWorkflow,
TemporalDurability,
)
from temporalio import workflow
from temporalio.workflow import ActivityConfig
reviewer = Agent(
"openai:gpt-5.6-sol",
# Required under Temporal: activity names derive from the agent name.
# Renaming it in production breaks every workflow still in flight.
name="pr-reviewer",
instructions="Summarise the purchase request for a manager. Do not recommend.",
tools=[get_purchase_request, get_budget_remaining], # plain async functions
capabilities=[
TemporalDurability(
activity_config=ActivityConfig(start_to_close_timeout=timedelta(seconds=60))
)
],
)
@workflow.defn
class PurchaseApprovalWorkflow(PydanticAIWorkflow):
__pydantic_ai_agents__ = [reviewer] # the plugin registers their activities
@workflow.run
async def run(self, pr_number: str) -> str:
# Must be the async API: run_sync() inside a workflow raises UserError.
review = await reviewer.run(f"Review {pr_number}")
... # same notify / wait_condition / convert_pr_to_po steps as before
client = await Client.connect("localhost:7233", plugins=[PydanticAIPlugin()])Dua perbedaan dari plugin OpenAI menentukan bentuk kodenya. Function tool async biasa otomatis dialihkan lewat activity, jadi tidak ada langkah activity_as_tool. Sebagai gantinya, semua yang menyeberang ke activity harus bisa di-serialize oleh Pydantic, termasuk deps, model_settings, dan metadata tool, dan Temporal membatasi setiap payload 2 MB secara default. Timeout activity default-nya 60 detik kecuali Anda mengisi activity_config, dan dokumentasinya menyarankan mematikan retry bawaan client provider, misalnya max_retries diisi 0, agar retry policy Temporal tidak dikalikan dua lapis retry lainnya.
Temporal bukan satu-satunya cara membuat agent hidup lebih lama dari prosesnya. LangGraph menyimpan state graph per thread lewat checkpointer, yang dokumentasinya sebut untuk human-in-the-loop dan fault tolerance, dan Agents SDK bisa men-serialize run yang sedang pause dengan sendirinya. Perbedaannya ada pada apa yang disimpan, seberapa sering, dan apa yang harus Anda operasikan.
| Pendekatan | Apa yang disimpan | Setelah crash di tengah run | Cara menunggu manusia |
|---|---|---|---|
| OpenAI Agents SDK di Temporal | Setiap model call, tool activity, signal, dan timer di event history | Replay sampai langkah pertama yang belum punya hasil tercatat | Signal atau update ditambah wait_condition dengan timeout di sisi server |
| Pydantic AI dengan TemporalDurability | Event history yang sama, dengan payload hasil serialize Pydantic maksimal 2 MB per payload secara default | Perilaku replay sama; activity yang gagal di-retry sesuai policy yang dikonfigurasi | Signal atau update workflow di sekitar agent.run |
| LangGraph dengan checkpointer Postgres | State graph di setiap super-step, ditambah pending writes per node | Lanjut dari checkpoint terakhir yang tersimpan; seberapa baru tergantung durability mode | interrupt di dalam node, dilanjutkan dengan Command dan thread_id yang sama |
| RunState Agents SDK saja | Snapshot JSON dari run yang pause: tool call yang tertunda, argumen, dan keputusan approval | Hanya yang Anda simpan di interruption terakhir; crash di antara interruption menghilangkan run | needs_approval pada tool, lalu approve atau reject pada state yang dipulihkan |
Dalam praktiknya, pilihannya mengikuti bentuk pekerjaan. Jika agent hanyalah satu langkah dalam proses bisnis yang memang butuh timer, retry, dan audit history, Temporal memberi semuanya dari satu sistem. Jika agent itu sendiri adalah prosesnya dan berjalan di graph yang sudah Anda kelola, checkpointer LangGraph dalam mode sync lebih ringan dioperasikan. Snapshot RunState saja cocok untuk satu jeda approval, tetapi dokumentasi OpenAI memperingatkan bahwa snapshot itu tidak diautentikasi: muat hanya dari storage yang Anda percaya, karena isinya membawa argumen tool.
# LangGraph: durability is a per-call setting on a checkpointed graph.
# "exit" saves only when the run exits or interrupts: a crash mid-run loses it
# "async" saves while the next step runs: a small window can be lost
# "sync" saves before the next step starts: the one to use for ERP writes
graph.invoke(inputs, {"configurable": {"thread_id": "PR-2026-10-0042"}}, durability="sync")
# OpenAI Agents SDK without Temporal: a paused run is a JSON snapshot you own.
state = result.to_state()
db.save("PR-2026-10-0042", state.to_json()) # pending tool call + arguments
# ...days later, in another process...
state = await RunState.from_json(agent, db.load("PR-2026-10-0042"))
state.approve(state.get_interruptions()[0])
result = await Runner.run(agent, state)Durable execution menghilangkan satu kelas kegagalan dan membuat beberapa kesalahan lain lebih mudah terjadi. Sebelum agent seperti ini live di ERP:
Durability bukan otorisasi. Workflow yang di-replay akan tetap menyelesaikan konversi PO yang disetujui oleh orang yang salah jika signal handler tidak memeriksanya. Validasi siapa pengirim keputusan di API terautentikasi di depan signal, dan catat approver di dalam keputusan itu sendiri, sebelum masuk ke state workflow.
Aturan yang perlu dibawa sederhana: apa pun yang tidak boleh dilupakan agent masuk ke history, dan apa pun yang tidak boleh diulang agent masuk ke activity dengan idempotency key. Dengan model call dan tool di activity, penantian di timer sisi server, dan langkah baru di balik patch marker, approval pembelian bisa menunggu tiga hari melewati dua kali deploy dan tetap lanjut tepat di langkah tempat ia berhenti.
Sumber dan bacaan lanjutan