# ADR-120: Token Budget Hard Kill

**狀態**：Accepted
**日期**：2026-05-03（台北）
**決策者**：統帥
**範圍**：三層 token budget 架構、hard kill 機制、$47k 教訓、告警閾值
**關聯**：ADR-106（Agent contract budget_limit）、ADR-112（contract governance）

---

## 背景

### $47k Agent Loop 事故教訓

某 agentic AI 平台的 Agent 進入無限 LLM 呼叫迴圈（prompt injection 導致），48 小時內耗費 $47,000 USD。

事故發生的原因：
- **只有告警，沒有 hard kill**：超出預算閾值時發告警，但 Agent 繼續執行
- **沒有 token 計數**：每次 LLM call 的 token 消耗沒有即時統計
- **沒有分層 budget**：整個 platform 共享一個 budget 上限，單一 Agent 可以消耗所有額度

### 現況

- `budget_ledger` 表存在，但沒有 hard kill 機制
- LLM call 前沒有 pre-check（只在事後統計）
- 沒有 per-run budget limit（只有 per-provider total budget）

---

## 決策

### D1 — 三層 Budget 架構

```
Platform Budget（全域上限）
  └── Tenant Budget（per project_id）
        └── Agent Budget（per agent_id）
              └── Run Budget（per run_id，最細粒度）
```

**層次說明**：

| 層次 | 限制對象 | 存放位置 | 更新頻率 |
|------|---------|---------|---------|
| Platform | 全部 LLM 呼叫 | `config.py` + K8s Secret | 靜態 |
| Tenant | per project_id | `awooop_projects.budget_limit_usd` | 合約期間靜態 |
| Agent | per agent_id | Agent contract `budget_limit` | 版本更新 |
| Run | per run_id | `awooop_run_state.token_budget_remaining` | 每次 LLM call 更新 |

### D2 — Hard Kill 機制（三道防線）

**防線 1：Pre-call Budget Check（呼叫前）**

```python
async def check_budget_before_llm_call(
    project_id: str,
    agent_id: str,
    run_id: str,
    estimated_tokens: int
) -> None:
    """預估 token 消耗，超預算直接拒絕呼叫"""

    # Layer 1: Run budget
    run = await get_run(run_id)
    estimated_cost_usd = estimate_cost(estimated_tokens, model=run.model)
    if run.token_budget_remaining < estimated_cost_usd:
        raise BudgetExhaustedError(
            "E-BUDGET-001",
            f"Run {run_id} budget exhausted: remaining ${run.token_budget_remaining:.4f}"
        )

    # Layer 2: Tenant budget（Redis 快取，每分鐘同步 DB）
    tenant_budget = await get_tenant_budget_remaining(project_id)
    if tenant_budget < estimated_cost_usd:
        raise BudgetExhaustedError(
            "E-BUDGET-002",
            f"Tenant {project_id} budget exhausted"
        )

    # Layer 3: Platform budget（Redis 快取）
    platform_budget = await get_platform_budget_remaining()
    if platform_budget < estimated_cost_usd:
        raise BudgetExhaustedError(
            "E-BUDGET-003",
            "Platform budget exhausted — all LLM calls suspended"
        )
```

**防線 2：Post-call Accounting（呼叫後）**

```python
async def record_token_usage(
    project_id: str, agent_id: str, run_id: str,
    prompt_tokens: int, completion_tokens: int,
    model: str, provider: str
) -> None:
    actual_cost = calculate_cost(prompt_tokens, completion_tokens, model, provider)

    async with db_transaction():
        # 扣除 run budget
        await db.execute(
            text("""
                UPDATE awooop_run_state
                SET token_budget_remaining = token_budget_remaining - :cost,
                    total_tokens_used = total_tokens_used + :total_tokens
                WHERE run_id = :run_id
            """),
            {"cost": actual_cost, "total_tokens": prompt_tokens + completion_tokens,
             "run_id": run_id}
        )

        # 寫入 budget_ledger
        await db.execute(
            text("""
                INSERT INTO budget_ledger
                (project_id, agent_id, run_id, model, provider,
                 prompt_tokens, completion_tokens, cost_usd, recorded_at)
                VALUES (:project_id, :agent_id, :run_id, :model, :provider,
                        :prompt_tokens, :completion_tokens, :cost_usd, NOW())
            """),
            {...}
        )

    # 更新 Redis 快取（async，不在事務中）
    await update_budget_cache(project_id, actual_cost)
```

**防線 3：Emergency Platform Kill Switch（緊急停機）**

```python
# Redis key: platform:budget:emergency_kill
# SET platform:budget:emergency_kill "1" → 停止所有 LLM 呼叫

async def check_emergency_kill() -> None:
    if await redis.exists("platform:budget:emergency_kill"):
        raise BudgetExhaustedError(
            "E-BUDGET-004",
            "Emergency kill switch activated — contact platform admin"
        )
```

### D3 — Budget 欄位（awooop_run_state）

```sql
ALTER TABLE awooop_run_state
ADD COLUMN token_budget_usd NUMERIC(10, 4) NOT NULL DEFAULT 1.00,
ADD COLUMN token_budget_remaining NUMERIC(10, 4) NOT NULL DEFAULT 1.00,
ADD COLUMN total_tokens_used INT NOT NULL DEFAULT 0,
ADD COLUMN total_cost_usd NUMERIC(10, 4) NOT NULL DEFAULT 0.0000;
```

**預設 Run Budget**：
- 從 Agent contract 的 `run_budget_usd` 讀取
- 若未設定：預設 $1.00
- 上限（Platform 強制）：$10.00 per run

### D4 — 告警閾值（三段）

```python
BUDGET_ALERT_THRESHOLDS = {
    "warn": 0.80,    # 80% 用量 → Telegram 警告（不 kill）
    "critical": 0.95, # 95% 用量 → Telegram 緊急告警 + 人工確認建議
    "hard_kill": 1.00, # 100% → hard kill（E-BUDGET-001/002/003）
}
```

**告警通知（Telegram）**：
```
[BUDGET-WARN] Run {run_id} (project={project_id})
已使用 ${used:.3f} / ${limit:.3f} ({pct:.1f}%)
模型：{model}，Provider：{provider}
```

### D5 — Budget Ledger 表強化

```sql
-- 現有 budget_ledger 補充欄位
ALTER TABLE budget_ledger
ADD COLUMN IF NOT EXISTS project_id VARCHAR(64),
ADD COLUMN IF NOT EXISTS agent_id VARCHAR(128),
ADD COLUMN IF NOT EXISTS run_id UUID,
ADD COLUMN IF NOT EXISTS model VARCHAR(64),
ADD COLUMN IF NOT EXISTS provider VARCHAR(32),
ADD COLUMN IF NOT EXISTS prompt_tokens INT,
ADD COLUMN IF NOT EXISTS completion_tokens INT,
ADD COLUMN IF NOT EXISTS cost_usd NUMERIC(10, 4);

-- 分析用 index
CREATE INDEX idx_budget_ledger_project_date
    ON budget_ledger(project_id, recorded_at);

CREATE INDEX idx_budget_ledger_run
    ON budget_ledger(run_id);
```

### D6 — Agent Contract Budget 欄位

Agent contract（六合約之一）的 budget 欄位：

```json
{
  "agent_id": "openclaw-sre",
  "run_budget_usd": 0.50,        // per run 上限（預設 $0.50）
  "daily_budget_usd": 10.00,     // per day 上限
  "max_tokens_per_run": 100000,  // 輔助限制（token 數量）
  "llm_call_limit_per_run": 20   // 防止無限迴圈的最大 LLM 呼叫次數
}
```

**`llm_call_limit_per_run`**（防無限迴圈）：
- Agent 每次 LLM call 前，計算 `saga_steps` 中 `step_type=llm_call` 的數量
- 超過 `llm_call_limit_per_run` → E-BUDGET-005（LLM call limit exceeded）

---

## 後果

### Benefits
- 三層 budget 防護：Platform / Tenant / Run 層層限制
- Pre-call check：在發出 API request 前就攔截，不產生費用
- Emergency kill switch：緊急情況下一個 Redis key 停止所有 LLM 呼叫
- `llm_call_limit_per_run`：從根本防止 $47k 事故（無限迴圈）

### Costs
- 每次 LLM call 前增加 3 次 Redis get（約 1-2ms 總延遲）
- Post-call accounting 有 DB write（已有 budget_ledger，只是補欄位）
- Budget cache 與 DB 可能有最多 1 分鐘的不一致（可接受）

### Risks
- Token 預估（estimated_tokens）不準確 → Pre-call check 誤殺或漏殺
- 緩解：estimated_tokens 保守估計（prompt token count × 1.5），寧可誤殺不漏殺
- Budget cache Redis 失效 → 退回 DB 查詢（比較慢但不 bypass）

---

## 驗收標準

- [ ] `awooop_run_state` 新增 budget 相關欄位（Phase 1 migration）
- [ ] Pre-call budget check 攔截超預算的 LLM 呼叫（整合測試）
- [ ] `llm_call_limit_per_run` 超過 → E-BUDGET-005（整合測試）
- [ ] Emergency kill switch：Redis SET → 所有 LLM 呼叫停止（整合測試）
- [ ] 80% budget 告警送達 Telegram（整合測試）
- [ ] EwoooC budget 與 AWOOOI budget 獨立計算（ADR-115 D5 驗收）

## 關聯

- ADR-106（Agent contract budget_limit 欄位定義）
- ADR-112（contract governance，Agent contract publish/activate）
- ADR-119（SAGA，run 失敗後 budget 狀態保留）
- ADR-121（OTel，token 使用量 telemetry 上報）
