# budget.mjs

## Огляд

Модуль `budget.mjs` реалізує **запобіжник бюджету** (budget guard) для автономного режиму диспатчера (відповідно до spec §9.4). Його основна задача — обмежити кількість викликів зовнішнього API, які робить `SubagentRunner`, щоб запобігти неконтрольованим витратам у середовищах, де нема людини-оператора для ручної зупинки (наприклад, на сервері або в CI).

Модуль реалізує патерн **декоратор/обгортка**: приймає базовий runner, що має метод `runStep`, і повертає функціонально еквівалентний об'єкт, який додатково:

- веде лічильник викликів `runStep`;
- порівнює лічильник із заданим лімітом `maxApiCalls` перед кожним викликом;
- кидає кастомну помилку `BudgetExceeded` при перевищенні ліміту;
- логує кожен виклик через переданий колбек `log`.

Окремо експортується клас помилки `BudgetExceeded`, який споживачі модуля (зокрема `run`-функція диспатчера, §9.4) можуть ловити для коректного завершення з відповідним статусом.

У коментарях файлу зазначено, що параметр `maxCostUsd` (бюджет за вартістю в доларах) поки не реалізований і запланований на майбутнє — на момент написання модуль рахує лише факти виклику, а не споживані токени чи вартість.

## Експорти / API

Модуль експортує два ідентифікатори (обидва — іменовані експорти, default-експорту немає):

| Експорт          | Тип                   | Призначення                                                                                      |
| ---------------- | --------------------- | ------------------------------------------------------------------------------------------------ |
| `BudgetExceeded` | `class extends Error` | Кастомний клас помилки, що сигналізує про вичерпання бюджету `maxApiCalls`.                      |
| `withBudget`     | функція               | Фабрика, що обгортає переданий runner лічильником і поверненням нового runner-сумісного об'єкта. |

## Функції

### `class BudgetExceeded extends Error`

**Сигнатура:** `class BudgetExceeded extends Error {}`

**Опис:** Маркерний підклас стандартної `Error`. Тіло класу порожнє — використовується виключно для відрізнення цього типу помилки від інших через `instanceof BudgetExceeded` або зіставлення в `try/catch`. Конструктор успадковується від `Error` і приймає рядок-повідомлення.

**Параметри:**

- `message` (string, через `super`) — текст помилки. У місці кидання передається рядок виду `budget: вичерпано maxApiCalls=<N>`.

**Side effects:** жодних.

**Кидає:** не застосовно (це сам клас помилки, його екземпляр кидається в `withBudget`).

### `withBudget(runner, opts)`

**Сигнатура:**

```
withBudget(
  runner: { backend?: string, runStep: (prompt: string, opts?: object) => object },
  opts?: { maxApiCalls?: number, log?: (m: string) => void }
): {
  backend: string,
  runStep: (prompt: string, opts?: object) => Promise<object>,
  readonly calls: number
}
```

**Параметри:**

- `runner` (object, обовʼязковий) — базовий runner, який потрібно обмежити бюджетом. Інтерфейс:
  - `backend?: string` — опційний ідентифікатор бекенду (пробросовується у поверненому обʼєкті).
  - `runStep(prompt, opts?) => object` — метод виконання одного кроку (виклику API). Повертати може як значення, так і `Promise`; в обгортці він викликається без `await`, але метод обгортки оголошено `async`, тож результат буде успішно "розгорнуто" викликаючою стороною.
- `opts` (object, опційний, за замовчуванням `{}`) — налаштування бюджету:
  - `maxApiCalls?: number` — максимально дозволена кількість викликів `runStep`. Якщо не передано, використовується `Number.POSITIVE_INFINITY` (фактично — без ліміту).
  - `log?: (m: string) => void` — колбек логування. Якщо не передано, використовується no-op `() => {}`.

**Повертає:** новий обʼєкт-обгортку з такими членами:

- `backend` — копія значення `runner.backend` (один до одного, через звичайне присвоєння у літералі). Допомагає вищим шарам визначати, з яким бекендом працює runner, без розпаковки обгортки.
- `get calls()` — getter (read-only ззовні), що повертає поточне значення внутрішньої змінної `calls` (кількість зроблених викликів). Дозволяє споживачам читати лічильник, але не дає його модифікувати.
- `async runStep(prompt, stepOpts)` — обгортка над `runner.runStep`. Алгоритм:
  1. Якщо `calls >= maxApiCalls` — кидає `new BudgetExceeded('budget: вичерпано maxApiCalls=<maxApiCalls>')`. Перевірка виконується **до** інкременту, тобто `maxApiCalls=N` дозволяє рівно `N` викликів `runStep`, які успішно дійдуть до делегування у внутрішній runner.
  2. Інкрементує `calls` на 1.
  3. Викликає `log` із повідомленням виду `budget: API-виклик <calls>/<maxApiCalls>`.
  4. Повертає результат `runner.runStep(prompt, stepOpts)` (як значення промісу, бо метод `async`).

**Side effects:**

- Мутує закриту (замкнену в замиканні) змінну `calls` — інкремент на кожен валідний виклик.
- Викликає переданий `log` як побічний ефект логування (виклик відбувається **після** інкременту, тому в логах перший виклик буде `1/<maxApiCalls>`).
- Делегує виклик `runner.runStep`, що має власні side effects (мережа, файлова система тощо — залежно від реалізації runner).

**Кидає:**

- `BudgetExceeded` — коли бюджет уже вичерпано на момент чергового виклику `runStep`. У цьому випадку внутрішній `runner.runStep` **не** викликається.
- Все, що кидає `runner.runStep`, проходить наскрізь без обгортання (бо результат повертається, а не `await`-иться у tryблоку).

**Особливості реалізації:**

- Лічильник зберігається в локальній змінній `let calls`, доступ ззовні — лише через getter, що робить значення фактично read-only. Прямого сетера немає.
- `maxApiCalls ?? Number.POSITIVE_INFINITY` — використання nullish coalescing: значення `0` залишається `0` (тобто 0 викликів дозволено = одразу `BudgetExceeded`). Тільки `undefined`/`null` замінюються на `Infinity`.
- `log` за замовчуванням — функція-заглушка `() => {}`, тож виклик безпечний у будь-якому випадку.
- Перевірка `calls >= maxApiCalls` робиться перед інкрементом: для `maxApiCalls=3` третій виклик пройде (бо до інкременту `calls=2 < 3`), четвертий — впаде з `BudgetExceeded`.

## Залежності

**Зовнішні (npm/standard):** жодних — модуль не імпортує нічого.

**Внутрішні (проєктні):** жодних — модуль самодостатній.

**Глобальні залежності runtime:**

- `Error` — стандартний клас JavaScript (базовий для `BudgetExceeded`).
- `Number.POSITIVE_INFINITY` — стандартна константа.

**Стандарт мови:** використовуються ES-modules (`export`), `class` (ES2015), `async`/`await` (ES2017), nullish coalescing `??` (ES2020), getter у літералі обʼєкта (ES5+). Файл має розширення `.mjs`, що явно вказує Node.js на ESM-режим.

## Потік виконання / Використання

### Загальний потік

1. Споживач (наприклад, диспатчер у `run`) створює базовий `SubagentRunner` із власним методом `runStep`.
2. Перед запуском автономного циклу runner обгортається: `const guarded = withBudget(runner, { maxApiCalls: 50, log: logger.info })`.
3. У циклі дисперчера викликається `await guarded.runStep(prompt, opts)` стільки разів, скільки потрібно.
4. Поточне споживання можна читати через `guarded.calls`.
5. При перевищенні `maxApiCalls` наступний `runStep` кине `BudgetExceeded`, який споживач ловить і завершує сесію з відповідним статусом (§9.4).

### Приклад використання

```js
import { withBudget, BudgetExceeded } from './budget.mjs'

const baseRunner = {
  backend: 'claude',
  async runStep(prompt, opts) {
    // ... виклик API
    return { text: '...', tokens: 123 }
  }
}

const runner = withBudget(baseRunner, {
  maxApiCalls: 10,
  log: msg => console.log(msg)
})

try {
  for (const step of plan) {
    const result = await runner.runStep(step.prompt)
    // ... обробка
  }
} catch (err) {
  if (err instanceof BudgetExceeded) {
    console.error('Зупинка: бюджет вичерпано після', runner.calls, 'викликів')
    process.exit(2)
  }
  throw err
}
```

### Контракт з вищими шарами

- **`SubagentRunner`** має надавати `{ backend, runStep(prompt, opts) }`. Обгортка не вимагає інших полів; усе, що поза `backend` і `runStep`, **губиться** при обгортанні (їх немає у поверненому літералі).
- **`run`-функція (§9.4)** має ловити `BudgetExceeded` окремо від інших помилок, інакше бюджет проявиться як звичайний краш.
- **Тестування:** клас `BudgetExceeded` пустий, тож порівнювати краще через `instanceof`, а не через `err.name` чи `err.constructor.name` (хоча обидва теж працюватимуть стандартно для підкласу `Error`).

### Граничні випадки

- `maxApiCalls: 0` — будь-який перший виклик `runStep` одразу кидає `BudgetExceeded`.
- `maxApiCalls: undefined` (опція не передана) — фактично без обмеження (Infinity).
- `maxApiCalls: Infinity` явно — те саме, без обмеження.
- `opts` не переданий — використовуються всі дефолти (`Infinity` і no-op log).
- `runner.backend === undefined` — у поверненому обʼєкті `backend` буде `undefined` (типізація показує `string`, але рантайм цьому не перешкоджає).
- `runner.runStep` синхронний — все одно повертає Promise, бо обгортка `async`.

### Звʼязок зі spec

Файл явно посилається на **spec §9.4** ("Budget guard для автономного режиму"). Логіка цього модуля є реалізацією контракту, описаного в специфікації: лічильник API-викликів + кастомна помилка `BudgetExceeded` як механізм аварійного виходу.
