Перейти к основному содержимому

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Проекция событий, нужная для следующего решения
TraceTelemetry-представление 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

CurrentEventNextКомментарий
runningtool_requestedrunning + pendingintent сохранён до effect
runningapproval_requiredwaiting_approvalmodel не вызывается снова
waiting_approvalapproval_resolvedrunningapprove/edit/reject — данные event
runningrun_interruptedinterruptedпредыдущий status сохранён
interruptedrun_resumedprevious statusвозможен возврат к approval
runningfinal_answercompletedterminal
runningbudget_exhaustedfailed/budget terminalбольше model steps нет

Конкурентность

Если два worker одновременно возобновят один run, простой reducer не предотвратит двойной effect. Helios выдаёт одному worker lease с TTL и монотонным fencing token, обновляет lease heartbeat-ом и возвращает busy конкурентному resume. Истёкший worker не может append-ить событие со старым fence. Но lease сам по себе не отменяет уже отправленный внешний запрос, поэтому нужны все уровни:

  1. lease + fencing token и compare-and-swap по sequence;
  2. operation ID на внешнем эффекте;
  3. idempotency или downstream fence на стороне domain adapter;
  4. уникальность tool_completed для call;
  5. reconciliation после неопределённого timeout.

TTL выбирают длиннее нормального scheduling jitter, но короче допустимого времени takeover. Renewal уменьшает ложные takeover, однако не является доказательством владения после network partition: его даёт только fence, проверенный при commit.

Варианты хранения

РешениеВыигрышЦенаКогда плохо
Только snapshotБыстрый readСлабый audit, трудно replayRegulated/long-running work
Event log + projectionReplay и объяснимостьМиграции событий, storageМалый one-shot
Workflow engineTimers, 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?