Sistem autonomous agent yang menjalankan long task planning (seperti ReAct, Plan-and-Solve, atau tree-of-thought) sering mengalami kegagalan pada sub-langkah tertentu. Ketika terjadi step failure, timeout jaringan, atau diskrepansi output, agent umumnya memicu mekanisme replanning atau step-level retry. Tanpa kontrak API yang tepat, pemanggilan ulang pada tool non-idempotent (misalnya pemotongan saldo, provisioning instance cloud, atau pengiriman webhook) akan memicu eksekusi ganda (duplicate side-effects).

Solusi standar seperti HTTP retry biasa tidak memadai untuk AI agent karena LLM cenderung merumuskan ulang request atau mengirimkannya kembali dengan jeda waktu signifikan di tengah sesi perencanaan. Artikel ini menguraikan desain kontrak API tool yang deterministik menggunakan header Idempotency-Key komposit, penanganan status in-flight, manajemen storage state, dan mitigasi token auth kedaluwarsa.

Anatomi Masalah: Duplicate Side-Effects pada Replanning

Pada pipeline eksekusi task panjang, arsitektur agent umumnya memecah tujuan akhir menjadi DAG (Directed Acyclic Graph) langkah eksekusi. Masalah muncul dalam skenario berikut:

  1. Agent mengeksekusi Step 3: Tool provision_database.
  2. Backend API menerima request, mulai membuat database, tetapi koneksi HTTP antara agent runner dan API mengalami timeout sebelum response 200 OK diterima agent.
  3. Agent evaluator mendeteksi timeout sebagai error, lalu memicu replanning. Hasil replanning memutuskan untuk mengulang Step 3 dengan payload yang identik atau sedikit dimodifikasi.
  4. Jika API backend tidak memiliki proteksi idempotensi level-langkah, instance database kedua akan dibuat, menghasilkan kebocoran resource dan inkonsistensi state runtime agent.

Mengandalkan client-side UUID acak sebagai Idempotency-Key sering kali gagal pada sistem agent, karena loop agent yang memicu replan dapat menghasilkan ID baru jika prompt generation tidak dipaksa menghasilkan ID deterministik.

Desain Kunci: Step-Level Idempotency Key

Idempotency key harus terikat secara ketat pada siklus hidup task planning agent. Kunci dibentuk secara deterministik di level runtime orchestrator sebelum request dikirim ke tool endpoint:

Idempotency-Key: <plan_session_id>:<step_index>:<args_hash>

Komponen pembentuk key:

  • plan_session_id: UUID sesi root planning task agent yang unik untuk satu siklus pekerjaan dari awal hingga selesai.
  • step_index: Indeks urutan langkah deterministik di dalam rencana kerja (misal: step_003).
  • args_hash: SHA-256 dari representasi JSON kanonikal (terurut berdasarkan key/lexicographically sorted) dari parameter eksekusi tool.

Mengapa menyertakan args_hash? Jika agent melakukan replanning dan memutuskan mengubah parameter kritis (misalnya mengganti region dari ap-southeast-1 ke us-east-1) pada step yang sama, kunci harus dianggap sebagai operasi baru yang berbeda, bukan replay dari operasi sebelumnya.

Siklus Hidup Eksekusi dan Penanganan Status In-Flight

State eksekusi tool di key-value store (seperti Redis) memerlukan mesin status minimal: IN_PROGRESS, COMPLETED, dan FAILED.

Penanganan Race Condition: 409 Conflict vs Waiting Polling

Ketika dua worker agent secara tidak sengaja memicu step yang sama, atau ketika retry tiba saat proses pertama masih berjalan di backend, backend memiliki dua opsi pendekatan arsitektural:

  • Waiting/Polling: Connection ditahan (long-polling) sampai proses pertama selesai. Pendekatan ini rentan menghabiskan worker thread server dan memicu cascading timeout pada client agent.
  • 409 Conflict (Direkomendasikan): Tolak eksekusi kedua secara instan dengan response 409 Conflict dan sertakan header Retry-After. Pola ini mengembalikan kendali ke agent orchestrator untuk menentukan strategi backoff tanpa membebani resource I/O server.

Alur Status Redis

  1. Acquire Lock: Eksekusi perintah atomic SET key "IN_PROGRESS" NX EX <ttl>.
  2. Jika gagal (key sudah ada): Baca nilai key. Jika nilainya IN_PROGRESS, kembalikan HTTP 409 Conflict. Jika sudah ada data payload tersimpan, kembalikan response tersimpan langsung (HTTP 200/201 replay) dengan header Idempotent-Replayed: true.
  3. Jika berhasil: Jalankan operasi internal tool, simpan output serialisasi ke Redis, lalu kembalikan output ke client agent.

TTL State Idempotensi di Key-Value Store

Berbeda dengan cache API standar (1-5 menit), TTL idempotensi pada agent long-task planning harus dihitung berdasarkan batas atas runtime pekerjaan. Jika sebuah agent diizinkan menjalankan workflow hingga 2 jam (termasuk human-in-the-loop validation):

  • TTL Status IN_PROGRESS: Diberi batas waktu timeout operasi tool individual ditambah toleransi jaringan (misal: 60-120 detik). Menggunakan TTL pendek pada status lock ini mencegah deadlock permanen jika proses backend crash sebelum sempat mengupdate status.
  • TTL Status COMPLETED / Replay: Harus bertahan selama durasi maksimal sesi planning aktif ditambah buffer audit (misal: 24 hingga 48 jam). Setelah TTL ini expired, memory Redis dibersihkan otomatis.

Mitigasi Auth Token Kedaluwarsa pada Task Durasi Panjang

Eksekusi long task planning yang memakan waktu belasan menit hingga beberapa jam rentan menemui kendala 401 Unauthorized di tengah proses karena access token JWT user kedaluwarsa. Strategi mitigasi yang perlu diterapkan pada layer kontrak API:

  • OBO (On-Behalf-Of) Token Exchange: Orchestrator agent memegang refresh token terenkripsi atau credentials machine-to-machine (m2m) berbasis OAuth2 Client Credentials dengan scope delegasi. Orchestrator memperbarui access token sebelum memanggil tool endpoint, tidak langsung menggunakan raw JWT user awal.
  • Asynchronous Decoupled Execution: Untuk step yang memakan waktu di atas batas aman timeout HTTP (misal > 30 detik), kontrak tool harus dirancang asinkron: endpoint tool mengembalikan 202 Accepted dengan resource tracking URL. Agent melakukan polling status menggunakan token yang di-refresh secara berkala.

Implementasi Handler Tool API (FastAPI + Redis)

Berikut implementasi endpoint tool minimalis menggunakan Python, FastAPI, dan Redis dengan pola atomic check, lock, dan response replay.

import hashlib
import json
from typing import Any, Dict
from fastapi import FastAPI, Header, HTTPException, Response, status
import redis

app = FastAPI()
redis_client = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)

LOCK_TTL_SECONDS = 120        # Lock timeout untuk mencegah dangling state
RESULT_TTL_SECONDS = 86400    # 24 jam untuk replay response agent

def generate_canonical_hash(payload: Dict[str, Any]) -> str:
    canonical_json = json.dumps(payload, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(canonical_json.encode("utf-8")).hexdigest()

@app.post("/tools/provision-database")
def provision_database(
    payload: Dict[str, Any],
    response: Response,
    idempotency_key: str = Header(..., alias="Idempotency-Key")
):
    # Validasi format key: plan_id:step_index:hash
    parts = idempotency_key.split(":")
    if len(parts) != 3:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Format Idempotency-Key harus: <plan_id>:<step_index>:<args_hash>"
        )
    
    expected_hash = generate_canonical_hash(payload)
    if parts[2] != expected_hash:
        raise HTTPException(
            status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
            detail="Payload tidak cocok dengan args_hash pada Idempotency-Key"
        )

    redis_key = f"idempotency:{idempotency_key}"

    # 1. Atomic lock menggunakan SET NX
    acquired = redis_client.set(redis_key, json.dumps({"status": "IN_PROGRESS"}), nx=True, ex=LOCK_TTL_SECONDS)

    if not acquired:
        raw_val = redis_client.get(redis_key)
        if not raw_val:
            raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="State lookup failure")

        cached_data = json.loads(raw_val)

        # 2. Tangani state IN_PROGRESS
        if cached_data.get("status" developments) == "IN_PROGRESS":
            response.headers["Retry-After"] = "5"
            raise HTTPException(
                status_code=status.HTTP_409_CONFLICT,
                detail="Task langkah ini sedang berjalan. Lakukan retry sesuai nilai Retry-After."
            )

        # 3. Replay cached response
        response.headers["Idempotent-Replay"] = "true"
        return cached_data.get("response")

    # Eksekusi side-effect
    try:
        # Simulasi mutasi database/resource
        result = {
            "instance_id": f"db-{parts[0][:8]}",
            "status": "ready",
            "allocated_storage": payload.get("storage_gb", 20)
        }

        # Simpan hasil untuk replay
        cache_payload = {
            "status": "COMPLETED",
            "response": result
        }
        redis_client.set(redis_key, json.dumps(cache_payload), ex=RESULT_TTL_SECONDS)
        return result

    except Exception as exc:
        # Hapus lock agar step dapat dicoba ulang jika kegagalan bersifat internal
        redis_client.delete(redis_key)
        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc))

Trade-offs dan Pertimbangan Sistem

  • Penyimpanan Payload Respons: Menyimpan respons berukuran besar di Redis dapat menghabiskan memori. Simpan respons di object storage (S3/GCS) dan letakkan URI referensinya di Redis jika ukuran payload tool melebihi ambang batas aman (> 100 KB).
  • Deterministic Serializer: Penyusunan args_hash wajib menggunakan JSON serializer yang mengurutkan dictionary keys secara konsisten (sort_keys=True) untuk mencegah miss hash pada object JSON dengan urutan property acak.
  • Graceful Recovery: Selalu pastikan blok exception handling menghapus lock status IN_PROGRESS jika terjadi error infrastruktur lokal sebelum mutasi dimulai. Jika mutasi sudah separuh jalan, tandai status sebagai FAILED agar agent dapat melakukan intervensi korektif yang tepat.