Agent loop and state
После главы вы сможете написать bounded loop, выбрать state boundaries и восстановить run из событий после падения процесса.
Словарь
| Термин | Значение |
|---|---|
| Thread | Долгоживущая пользовательская линия разговора |
| Turn | Один пользовательский input и связанная реакция системы |
| Run | Одна попытка runtime достичь goal; может пережить process restart |
| Step | Один model decision или контролируемый переход внутри run |
| Event | Неизменяемый факт: решение, запрос tool, outcome, approval |
| State | Проекция событий, нужная для следующего решения |
| Trace | Telemetry-представление latency/cost/causality; не recovery store |
Thread может содержать много turns; turn может запустить несколько runs; run — несколько model/tool steps.
Минимальный bounded loop
for (let step = 1; step <= budget.maxSteps; step += 1) {
const request = contextBuilder.build(state);
const decision = await model.complete(request);
if (decision.kind === 'final') return completed(decision.content);
const result = await tools.execute(decision.call, executionContext);
state = appendObservation(state, result);
}
return budgetExhausted();
maxSteps — safety invariant. Но одного числа мало: production budget также ограничивает wall time, model/tool calls, деньги, внешние writes и повтор одной стратегии.
Terminal reason — часть API
Не сводите всё к «вернули строку»:
completed— есть final answer/outcome;approval_required— безопасная пауза, не ошибка;budget_exhausted— прогресс остановлен лимитом;failed— продолжение без нового решения невозможно;interrupted— control-plane остановил выполнение, но underlying status сохранён;- в более полном runtime полезны
cancelled,timed_out,escalated.
Caller принимает разные решения: показать ответ, открыть approval UI, поставить run в очередь, эскалировать человеку или retry только допустимый слой.
Message history не является run state
Наивная реализация хранит Message[]. Этого недостаточно, потому что из текста трудно надёжно вывести:
- был ли tool уже выполнен;
- какая операция ждёт approval;
- какой retry attempt исчерпан;
- что произошло до crash;
- какой event подтвердил final outcome.
Messages нужны модели и пользователю. Events нужны системе.
Append-only event history
Вертикальный Helios-run создаёт:
run_started
model_decision(tool)
tool_requested
tool_completed
model_decision(final)
final_answer
Event должен описывать свершившийся факт, а не намерение «когда-нибудь сделать». Для side effect важны оба факта: tool_requested сохраняется до действия, tool_completed — только после подтверждённого результата.
Reducer: events → state
Helios store намеренно сохраняет только RunInput + RunEvent[]. При load reducer восстанавливает messages, pending tool, call-scoped approval/retry, terminal cause, completed operation ledger, next step и status. Изменение готовой projection без соответствующего event исчезнет после загрузки. Даже toolOutcomesByCallId — лишь производный индекс для удобного чтения, не второй authoritative store.
Это учебная in-memory реализация протокола. Каждый append проверяет ожидаемый sequence и, для worker event, fencing token активного lease. Production event store должен делать эти проверки атомарно с записью; «сначала прочитать revision, потом отдельно записать» оставляет race window.
State transition table
| Current | Event | Next | Комментарий |
|---|---|---|---|
| running | tool_requested | running + pending | intent сохранён до effect |
| running | approval_required | waiting_approval | model не вызывается снова |
| waiting_approval | approval_resolved | running | approve/edit/reject — данные event |
| running | run_interrupted | interrupted | предыдущий status сохранён |
| interrupted | run_resumed | previous status | возможен возврат к approval |
| running | final_answer | completed | terminal |
| running | budget_exhausted | failed/budget terminal | больше model steps нет |
Конкурентность
Если два worker одновременно возобновят один run, простой reducer не предотвратит двойной effect. Helios выдаёт одному worker lease с TTL и монотонным fencing token, обновляет lease heartbeat-ом и возвращает busy конкурентному resume. Истёкший worker не может append-ить событие со старым fence. Но lease сам по себе не отменяет уже отправленный внешний запрос, поэтому нужны все уровни:
- lease + fencing token и compare-and-swap по sequence;
- operation ID на внешнем эффекте;
- idempotency или downstream fence на стороне domain adapter;
- уникальность
tool_completedдля call; - reconciliation после неопределённого timeout.
TTL выбирают длиннее нормального scheduling jitter, но короче допустимого времени takeover. Renewal уменьшает ложные takeover, однако не является доказательством владения после network partition: его даёт только fence, проверенный при commit.
Варианты хранения
| Решение | Выигрыш | Цена | Когда плохо |
|---|---|---|---|
| Только snapshot | Быстрый read | Слабый audit, трудно replay | Regulated/long-running work |
| Event log + projection | Replay и объяснимость | Миграции событий, storage | Малый one-shot |
| Workflow engine | Timers, queues, durable tasks | Инфраструктура и semantics | Простая короткая задача |
Helios и реальный аналог
Helios отвечает о заказе за два model steps. Для neobank тот же loop может сначала прочитать payment state, затем подготовить reversal, остановиться на approval и продолжить через час на другом worker. Thread остаётся тем же, process и model request — нет.
Лаборатория
npm run test:course:pattern -- "event|crash"
Найдите в тесте момент crash после effect, но до tool_completed. Объясните, почему reducer снова показывает pending tool и почему повтор не создаёт вторую reservation.
Self-check
- Какие IDs стабильны между retry и process restart?
- Может ли state быть восстановлен без transcript и trace backend?
- Что записывается до side effect?
- Как различаются waiting и failed?
- Где предотвращается concurrent resume?
- Как версионируются старые события после изменения schema?