Часть 2

Основной цикл

Сердцебиение агента: поток, действие, наблюдение, повторение.

Глава 5: Цикл агента

Бьющееся сердце

Глава 4 показала, как слой API преобразует конфигурацию в потоковые HTTP-запросы — как собирается клиент, как составляются системные подсказки, как ответы приходят как server-sent events. Этот слой управляет механикой общения с моделью. Но одиночный вызов API — это ещё не агент. Агент — это цикл: вызвать модель, выполнить инструменты, вернуть результаты назад, вызвать модель снова, пока задача не завершена.

У каждой системы есть центр тяжести. В базе данных это — движок хранения. В компиляторе — промежуточное представление. В Агенте этим центром является query.ts — один файл на 1 730 строк, содержащий асинхронный генератор, который запускает каждое взаимодействие: от первого нажатия клавиши в REPL до последнего вызова инструмента в безголовом запуске с --print.

Это не преувеличение. Существует ровно один кодовый путь, который говорит с моделью, выполняет инструменты, управляет контекстом, восстанавливается после ошибок и решает, когда остановиться. Эта кодовая ветка — функция query(). REPL вызывает её. SDK вызывает её. Суб-агенты вызывают её. Безголовый раннер вызывает её. Если вы используете Агент, вы находитесь внутри query().

Файл плотный, но он не сложен так, как запутанные иерархии наследования. Он сложен как подлодка: один корпус с множеством избыточных систем, каждая добавлена потому, что океан нашёл способ пробраться внутрь. За каждым if стоит история. Каждое подавлённое сообщение об ошибке представляет реальную ошибку, когда потребитель SDK отключился в середине восстановления. Каждый порог предохранителя был настроен на основе реальных сессий, которые сжигали тысячи вызовов API в бесконечных петлях.

В этой главе прослеживается весь цикл от начала до конца. К её концу вы поймёте не только что происходит, но и почему существует каждый механизм и что ломается без него.


Почему асинхронный генератор

Первый архитектурный вопрос: почему цикл агента — генератор, а не событие с колбэками?

// Упрощено — показывает идею, не точные типы
async function* agentLoop(params: LoopParams): AsyncGenerator<Message | Event, TerminalReason>

Фактическая сигнатура выдаёт несколько типов сообщений и событий и возвращает дискриминированное объединение, кодирующее причину остановки цикла.

Три причины, в порядке важности.

Обратное давление (Backpressure). Эмиттер событий шлёт события, независимо от готовности потребителя. Генератор отдаёт значение только когда потребитель вызывает .next(). Когда рендерер REPL на React занят отрисовкой предыдущего кадра, генератор естественно приостанавливается. Когда потребитель SDK обрабатывает результат инструмента, генератор ждёт. Никакого переполнения буфера, никаких потерянных сообщений, никаких проблем «быстрый производитель / медленный потребитель».

Семантика возвращаемого значения. Тип возврата генератора — Terminal — дискриминированное объединение, точно кодирующее, почему цикл остановился. Это было нормальное завершение? Пользователь прервал? Исчерпан ли лимит токенов? Вмешался ли stop hook? Достигнут ли предел по количеству ходов? Неисправимая ошибка модели? Всего 10 различных терминальных состояний. Вызывающим не нужно подписываться на событие «end» и надеяться, что полезная нагрузка содержит причину. Они получают её как типизированное возвращаемое значение из for await...of или yield*.

Композиция через yield*. Внешняя функция query() делегирует внутреннему queryLoop() с yield*, который прозрачно форвардит каждое выданное значение и финальный возврат. Под-генераторы вроде handleStopHooks() используют тот же паттерн. Это создаёт чистую цепочку ответственности без колбэков, без промисов, обёрнутых в промисы, без шаблонного кода для форвардинга событий.

Такой выбор имеет цену — асинхронные генераторы в JavaScript нельзя «перемотать назад» или форкнуть. Но цикл агента этого не требует. Это строго однонаправленный автомат состояний.

Ещё одна тонкость: синтаксис function* делает функцию ленивой. Тело не выполняется до первого вызова .next(). Это означает, что query() возвращает мгновенно — вся тяжёлая инициализация (снимок конфигурации, предзагрузка памяти, трекер бюджета) происходит только когда потребитель начинает вытягивать значения. В REPL это означает, что пайплайн рендеринга React уже настроен до первой строки цикла.


Что дают вызывающие стороны

Прежде чем проследить цикл, полезно знать, что в него передаётся:

// Упрощено — иллюстрирует ключевые поля
type LoopParams = {
  messages: Message[]
  prompt: SystemPrompt
  permissionCheck: CanUseToolFn
  context: ToolUseContext
  source: QuerySource         // 'repl', 'sdk', 'agent:xyz', 'compact', и т.д.
  maxTurns?: number
  budget?: { total: number }  // Бюджет на задачу на уровне API
  deps?: LoopDeps             // Внедряется для тестирования
}

Особые поля:

  • querySource: строковый дискриминант типа 'repl_main_thread', 'sdk', 'agent:xyz', 'compact' или 'session_memory'. Многие условные ветви зависят от этого. Компактный агент использует querySource: 'compact', чтобы защита от блокирующих лимитов не приводила к дедлоку (компактный агент должен запускаться, чтобы уменьшать количество токенов).

  • taskBudget: уровень бюджета задачи в API (output_config.task_budget). Отличается от функции автопродолжения с бюджетом токенов +500k. total — бюджет на весь агентский ход; remaining вычисляется на каждой итерации из накопленного использования API и корректируется при границах компакции.

  • deps: опциональная инъекция зависимостей. По умолчанию productionDeps(). Это место, где тесты подменяют реальные вызовы модели, реальный компактор и детерминированные UUID.

  • canUseTool: функция, возвращающая разрешено ли использование конкретного инструмента. Это слой прав — проверяет настройки доверия, решения hook’ов и текущий режим разрешений.


Двухслойная точка входа

Публичный API — тонкая обёртка вокруг реального цикла:

Внешняя функция оборачивает внутренний цикл, отслеживая, какие поставленные в очередь команды были потреблены во время хода. После завершения внутреннего цикла потреблённые команды помечаются как 'completed'. Если цикл выбрасывает ошибку или генератор закрывается через .return(), уведомления о завершении никогда не отправляются — упавший ход не должен помечать команды как успешно обработанные. Команды, поставленные в очередь во время хода (через команды / или уведомления задач), помечаются как 'started' внутри цикла и 'completed' в обёртке. Это сделано специально — при ошибке ход не должен подтверждать успешную обработку команд.


Объект состояния

Цикл несёт своё состояние в одном типизированном объекте:

// Упрощено — иллюстрирует ключевые поля
type LoopState = {
  messages: Message[]
  context: ToolUseContext
  turnCount: number
  transition: Continue | undefined
  // ... плюс счётчики восстановления, отслеживание компакции, ожидающие суммаризации и т.д.
}

Десять полей. Каждое заслуживает места:

ПолеЗачем нужно
messagesИстория разговора, растущая с каждой итерацией
toolUseContextМутируемый контекст: инструменты, abort controller, состояние агента, опции
autoCompactTrackingОтслеживает состояние автокомпакции: счётчик ходов, ID хода, подряд неудачи, флаг компактирования
maxOutputTokensRecoveryCountСколько попыток восстановления при превышении лимита вывода (максимум 3)
hasAttemptedReactiveCompactОдноразовая защита от бесконечных реактивных циклов компактирования
maxOutputTokensOverrideУстанавливается в 64K при эскалации, снимается позже
pendingToolUseSummaryПромис из прошлой итерации для «хаику»-резюме, разрешается во время текущего стриминга
stopHookActiveПредотвращает повторный запуск stop hooks после блокирующей попытки
turnCountМонотонный счётчик, проверяется относительно maxTurns
transitionПочему предыдущая итерация продолжилась — undefined в первой итерации

Иммутабельные переходы в мутируемом цикле

Вот паттерн, который встречается на каждом continue в цикле:

const next: State = {
  messages: [...messagesForQuery, ...assistantMessages, ...toolResults],
  toolUseContext: toolUseContextWithQueryTracking,
  autoCompactTracking: tracking,
  turnCount: nextTurnCount,
  maxOutputTokensRecoveryCount: 0,
  hasAttemptedReactiveCompact: false,
  pendingToolUseSummary: nextPendingToolUseSummary,
  maxOutputTokensOverride: undefined,
  stopHookActive,
  transition: { reason: 'next_turn' },
}
state = next

Каждый сайт continue конструирует полный новый объект State. Не state.messages = newMessages. Не state.turnCount++. Полная реконструкция. Выгода в том, что каждый переход самодокументирован. Вы можете прочитать любой continue и увидеть, какие поля меняются, а какие сохраняются. Поле transition в новом состоянии фиксирует почему цикл продолжается — тесты утверждают это, чтобы проверить, что сработал нужный путь восстановления.


Тело цикла

Вот полный поток выполнения одной итерации, сжатый до скелета:

Это весь цикл. Каждая фича в Агенте — от памяти до суб-агентов и восстановления после ошибок — либо питается из этой структуры итерации, либо в неё вкладывается.


Управление контекстом: четыре уровня сжатия

Перед каждым вызовом API история сообщений проходит через до четырёх стадий управления контекстом. Они выполняются в определённом порядке, и порядок имеет значение.

Слой 0: Бюджет результата инструмента (Tool Result Budget)

До любой компакции applyToolResultBudget() накладывает ограничения по размерам сообщений для результатов инструментов. Инструменты без конечного maxResultSizeChars не подпадают под это правило.

Слой 1: Snip Compact

Самая лёгкая операция. Snip физически удаляет старые сообщения из массива, оставляя boundary-сообщение, чтобы сигнализировать UI об удалении. Она сообщает, сколько токенов было освобождено, и это число используется в проверке порога автокомпакции.

Слой 2: Microcompact

Microcompact удаляет результаты инструментов, которые больше не нужны, идентифицируемые по tool_use_id. Для кешированного microcompact (который редактирует кэш API) boundary-сообщение откладывается до ответа API. Причина: оценки токенов на клиенте ненадёжны. Фактический cache_deleted_input_tokens из ответа API говорит, что реально было освобождено.

Слой 3: Context Collapse

Context collapse заменяет фрагменты беседы резюме. Он выполняется перед auto-compact, и порядок преднамерен: если collapse уменьшает контекст ниже порога автокомпакции, автокомпакт становится no-op. Это сохраняет детализированный контекст вместо превращения всего в монолитное резюме.

Слой 4: Auto-Compact

Самая тяжёлая операция: форк всей беседы Claude для суммаризации истории. В реализации есть предохранитель — после 3 подряд неудач попытки прекращаются. Это предотвращает кошмарную ситуацию, наблюдаемую в продакшне: сессии, застрявшие поверх лимита контекста, сжигающие по 250K вызовов API в день в бесконечной петле попыток компакции и отказов.

Пороги автокомпакции

Пороги вытекают из окна контекста модели:

effectiveContextWindow = contextWindow - min(modelMaxOutput, 20000)

Thresholds (relative to effectiveContextWindow):
  Auto-compact fires:      effectiveWindow - 13,000
  Blocking limit (hard):   effectiveWindow - 3,000
КонстантаЗначениеНазначение
AUTOCOMPACT_BUFFER_TOKENS13,000Запас токенов до эффективного окна для триггера автокомпакции
MANUAL_COMPACT_BUFFER_TOKENS3,000Оставляет место, чтобы команда /compact всё ещё работала
MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES3Порог срабатывания предохранителя

Буфер в 13 000 токенов означает, что автокомпакт запускается задолго до жёсткого лимита. Промежуток между триггером автокомпакции и блокирующим лимитом — это зона, где работает реактивная компакция: если проактивный автокомпакт не удался или отключён, реактивная компакция срабатывает на требовании (ошибке 413) и компактирует при необходимости.

Подсчёт токенов

Каноническая функция tokenCountWithEstimation комбинирует авторитетные отчёты API о количестве токенов (из последнего ответа) с грубой оценкой для сообщений, добавленных после этого ответа. Приближение консервативно — оно склоняется в сторону бо́льших оценок, из-за чего автокомпакт срабатывает чуть раньше, а не позже.


Стриминг модели

Цикл callModel()

Вызов API происходит внутри цикла while(attemptWithFallback), который даёт возможность для отката на запасную модель:

let attemptWithFallback = true
while (attemptWithFallback) {
  attemptWithFallback = false
  try {
    for await (const message of deps.callModel({ messages, systemPrompt, tools, signal })) {
      // Обрабатываем каждое входящее сообщение стрима
    }
  } catch (innerError) {
    if (innerError instanceof FallbackTriggeredError && fallbackModel) {
      currentModel = fallbackModel
      attemptWithFallback = true
      continue
    }
    throw innerError
  }
}

Когда это включено, StreamingToolExecutor начинает выполнять инструменты, как только их tool_use блоки приходят в процессе стрима — не дожидаясь полного завершения ответа. То, как инструменты оркеструются в конкурентные батчи, — тема главы 7.

Паттерн подавления (Withholding)

Это один из важнейших паттернов в файле. Восстановимые ошибки подавляются из потока выдачи:

let withheld = false
if (contextCollapse?.isWithheldPromptTooLong(message)) withheld = true
if (reactiveCompact?.isWithheldPromptTooLong(message)) withheld = true
if (isWithheldMaxOutputTokens(message)) withheld = true
if (!withheld) yield yieldMessage

Зачем подавлять? Потому что потребители SDK — Cowork, десктопное приложение — разрывают сессию при любом сообщении с полем error. Если вы выдадите ошибку «prompt-too-long», а затем успешно восстановитесь через реактивную компакцию, потребитель уже отключился. Петля восстановления продолжает работать, но никто не слушает. Поэтому ошибка откладывается, помещается в assistantMessages, чтобы последующие проверки восстановления могли её найти. Если все пути восстановления исчерпаны, отложенное сообщение наконец выступает наружу.

Откат модели (Model Fallback)

Когда ловится FallbackTriggeredError (высокая нагрузка на основную модель), цикл переключается на модель-резерв и повторяет попытку. Но подписи thinking-блоков привязаны к модели — перепроигрывание защищённого блока thinking от одной модели на другую приводит к ошибке 400. Код удаляет блоки подписи перед повторной попыткой. Все сиротеющие assistant-сообщения от неудачной попытки помечаются как tombstone, чтобы UI их удалил.


Восстановление после ошибок: лестница эскалации

Восстановление ошибок в query.ts — не одна стратегия. Это лестница возрастающих по агрессивности вмешательств, каждое срабатывает, когда предыдущее не помогло.

Защита от «смертельной спирали»

Самый опасный режим отказа — бесконечная петля. В коде есть несколько защит:

  1. hasAttemptedReactiveCompact: одноразовый флаг. Реактивная компакция срабатывает один раз на тип ошибки.
  2. MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3: жёсткий предел для попыток восстановления через несколько ходов.
  3. Порог-схема для автокомпакта: после 3 подряд неудач автокомпакт прекращает попытки вовсе.
  4. Не запускаем stop hooks при ответах с ошибкой: код явно возвращается до достижения stop hooks, когда последнее сообщение — ошибка API. Комментарий поясняет: «error -> hook blocking -> retry -> error -> … (hook добавляет токены в каждом цикле)».
  5. Сохранение hasAttemptedReactiveCompact через повторы stop hook’ов: когда stop hook возвращает блокирующие ошибки и заставляет повторить попытку, флаг реактивной компакции сохраняется. Комментарий документирует баг: «Сброс в false здесь вызывал бесконечную петлю, сжигая тысячи вызовов API».

Каждая из этих защит была добавлена потому, что кто-то столкнулся с этой ситуацией в продакшне.


Пример прохода: «Почините баг в auth.ts»

Чтобы конкретизировать цикл, проследим реальное взаимодействие через три итерации.

Пользователь вводит: Fix the null pointer bug in src/auth/validate.ts

Итерация 1: модель читает файл.

Цикл входит. Управление контекстом запускается (компакция не нужна — разговор короткий). Модель стримит ответ: «Let me look at the file.» Она эмиттит один tool_use блок: Read({ file_path: "src/auth/validate.ts" }). Streaming executor видит конкурентно-безопасный инструмент и начинает его сразу. К моменту, когда модель завершает текст ответа, содержимое файла уже в памяти.

Постстриминговая обработка: модель использовала инструмент, поэтому мы входим в путь обработки инструментов. Результат Read (содержимое файла с номерами строк) помещается в toolResults. В фоне запускается промис «хаику»-резюме. Состояние реконструируется с новыми сообщениями, transition: { reason: 'next_turn' }, и цикл продолжается.

Итерация 2: модель правит файл.

Управление контекстом снова запускается (всё ещё ниже порога). Модель стримит: «I see the bug on line 42 — userId can be null.» Она эмиттит Edit({ file_path: "src/auth/validate.ts", old_string: "const user = getUser(userId)", new_string: "if (!userId) return { error: 'unauthorized' }\nconst user = getUser(userId)" }).

Редактирование не является конкурентно-безопасным, поэтому streaming executor ставит его в очередь до завершения ответа. Затем срабатывает 14-шаговый pipeline выполнения: Zod валидация проходит, backfill входных данных расширяет путь, PreToolUse hook проверяет разрешения (пользователь подтверждает), и правка применяется. Ожидающее Haiku-резюме из итерации 1 разрешается во время стриминга — его результат выдаётся как ToolUseSummaryMessage. Состояние реконструируется, цикл продолжается.

Итерация 3: модель объявляет завершение.

Модель стримит: «I’ve fixed the null pointer bug by adding a guard clause.» Нет tool_use блоков. Мы переходим в путь “done”. Потребности в восстановлении от prompt-too-long нет. Лимит выходных токенов не превышен. Stop hooks запускаются — блокирующих ошибок нет. Проверка бюджета токенов проходит. Цикл возвращает { reason: 'completed' }.

Итого: три вызова API, два выполнения инструментов, один запрос разрешения у пользователя. Цикл обработал стриминг и выполнения инструментов, суммаризацию Haiku, перекрывающуюся с API-вызовом, и полный pipeline проверок разрешений — всё это через одну структуру while(true).


Бюджеты токенов

Пользователи могут запросить бюджет токенов для хода (например, +500k). Система бюджета решает, продолжать или остановиться после завершения ответа модели.

checkTokenBudget делает бинарное решение continue/stop по трём правилам:

  1. Суб-агенты всегда останавливаются. Бюджет — топ-уровневое понятие.
  2. Порог завершения на 90%. Если turnTokens < budget * 0.9, продолжаем.
  3. Детекция убывающей отдачи. После 3+ продолжений, если и текущая, и предыдущая дельта меньше 500 токенов, останавливаемся раньше. Модель даёт всё меньше и меньше полезного вывода.

Когда решение — «продолжить», вставляется нудж-сообщение, подсказывающее модели, сколько бюджета осталось.


Stop hooks: заставляют модель продолжать работать

Stop hooks запускаются, когда модель завершила ход без запроса инструментов — она считает, что готова. Hooks проверяют, действительно ли она закончила.

Пайплайн выполняет классификацию задачи по шаблону, запускает фоновые задачи (подсказки по подсказкам, извлечение памяти), а затем выполняет сами stop hooks. Когда stop hook возвращает блокирующие ошибки — «ты сказал, что закончил, но линтер нашёл 3 ошибки» — ошибки добавляются в историю сообщений и цикл продолжается с stopHookActive: true. Этот флаг предотвращает повторный запуск тех же hooks при повторной попытке.

Если stop hook сигнализирует preventContinuation, цикл немедленно завершится с { reason: 'stop_hook_prevented' }.


Переходы состояний: полный каталог

Каждый выход из цикла — одного из двух типов: Terminal (цикл возвращает) или Continue (цикл итеративно продолжается).

Терминальные состояния (10 причин)

ПричинаТриггер
blocking_limitКоличество токенов достигло жёсткого лимита, автокомпакт отключён
image_errorImageSizeError, ImageResizeError или неисправимая ошибка с медиа
model_errorНеисправимая исключительная ситуация API/модели
aborted_streamingПрерывание пользователем во время стриминга модели
prompt_too_longОтложенный 413 после исчерпания всех восстановлений
completedНормальное завершение (нет использования инструментов, или бюджет исчерпан, или ошибка API)
stop_hook_preventedStop hook явно заблокировал продолжение
aborted_toolsПрерывание пользователем во время выполнения инструментов
hook_stoppedPreToolUse hook остановил продолжение
max_turnsДостигнут лимит maxTurns

Состояния продолжения (7 причин)

ПричинаТриггер
collapse_drain_retryContext collapse освободил staged collapses после 413
reactive_compact_retryРеактивная компакция сработала после 413 или ошибки с медиа
max_output_tokens_escalateПопадание в 8K-ограничение, эскалация до 64K
max_output_tokens_recovery64K всё ещё превышен, многоходовое восстановление (до 3 попыток)
stop_hook_blockingStop hook вернул блокирующие ошибки, нужно повторить
token_budget_continuationБюджет токенов не исчерпан, вставлен nudge-сообщение
next_turnНормальное продолжение с использованием инструментов

Сиротеющие результаты инструментов: протокольная страховка

Протокол API требует, чтобы за каждым tool_use блоком шёл tool_result. Функция yieldMissingToolResultBlocks создаёт сообщения-ошибки tool_result для каждого tool_use блока, который модель эмиттит, но для которого не найден соответствующий результат. Без этой страховки падение во время стриминга оставило бы сиротящиеся tool_use блоки, которые вызвали бы протокольную ошибку при следующем вызове API.

Она срабатывает в трёх местах: внешний обработчик ошибок (крах модели), обработчик отката (переключение модели в середине стрима) и обработчик прерываний (interruption от пользователя). В каждом случае сообщение об ошибке разное, но механизм идентичен.


Обработка прерываний: два пути

Прерывания могут случиться в двух местах: во время стриминга и во время выполнения инструментов. В каждом случае поведение различается.

Прерывание во время стриминга: streaming executor (если активен) осушает оставшиеся результаты, генерируя синтетические tool_results для поставленных в очередь инструментов. Без executor’а yieldMissingToolResultBlocks заполняет пропуски. Проверка signal.reason отличает жёсткое прерывание (Ctrl+C) от submit-interrupt (пользователь отправил новое сообщение) — submit-interrupt пропускает сообщение о прерывании, потому что очередное сообщение пользователя уже даёт контекст.

Прерывание во время выполнения инструментов: аналогичная логика, с параметром toolUse: true в сообщении прерывания, сигнализирующим UI, что инструменты были в процессе выполнения.


Правила «мышления» (Thinking Rules)

Thinking / redacted_thinking блоки Claude имеют три неотвратимых правила:

  1. Сообщение, содержащее thinking-блок, должно быть частью запроса с max_thinking_length > 0
  2. Thinking-блок не может быть последним блоком в сообщении
  3. Thinking-блоки должны сохраняться на протяжении траектории ответа ассистента

Нарушение любого из этих правил приводит к непрозрачным ошибкам API. Код обрабатывает их в нескольких местах: обработчик отката удаляет signature-блоки (они привязаны к модели), pipeline компакции сохраняет защищённый хвост, а слой microcompact никогда не трогает thinking-блоки.


Внедрение зависимостей

Тип QueryDeps намеренно узок — четыре зависимости, не сорок:

Четыре внедрённых зависимости: вызов модели, компактор, микрокомпактор и генератор UUID. Тесты передают deps в параметры цикла, чтобы напрямую инъектировать фэйки. Использование typeof fn для определений типов сохраняет сигнатуры в синхронизации автоматически. Вместе с мутируемым State и внедряемыми QueryDeps, неизменяемый QueryConfig снимается снимком единожды при входе в query() — feature флаги, состояние сессии и переменные окружения фиксируются и больше не читаются. Трёхстороннее разделение (мутируемое состояние, неизменяемая конфигурация, внедряемые зависимости) делает цикл тестируемым и упрощает будущий рефактор в чистую функцию-редьюсер step(state, event, config).


Apply This: Построение собственного цикла агента

Используйте генератор, а не колбэки. Обратное давление идёт бесплатно. Семантика возвращаемого значения — бесплатно. Композиция через yield* — бесплатно. Циклы агентов строго однонаправленные — переписывать или форкать их не нужно.

Делайте переходы состояния явными. Реконструируйте полный объект состояния на каждом сайте continue. Многословность — это фича: она предотвращает баги частичного обновления и делает каждый переход самодокументированным.

Подавляйте восстанавливаемые ошибки. Если ваши потребители отключаются при ошибках, не выдавайте ошибки до тех пор, пока не убедитесь, что восстановление провалилось. Помещайте их во внутренний буфер, пытайтесь восстановиться и показывайте только при исчерпании всех вариантов.

Слойте управление контекстом. Сначала лёгкие операции (удаление), затем тяжёлые (суммаризация). Это сохраняет детализированный контекст, когда возможно, и прибегает к монолитным резюме только при необходимости.

Добавьте предохранители для каждой повторной попытки. Каждый механизм восстановления в query.ts имеет явный предел: 3 неудачи автокомпакта, 3 попытки восстановления по max-output, 1 попытка реактивной компакции. Без этих лимитов первая же продакшн-сессия, вызвавшая петлю «повторить при ошибке», сожжёт ваш бюджет API за ночь.

Минимальный скелет цикла агента, если вы начинаете с нуля:

async function* agentLoop(params) {
  let state = initState(params)
  while (true) {
    const context = compressIfNeeded(state.messages)
    const response = await callModel(context)
    if (response.error) {
      if (canRecover(response.error, state)) { state = recoverState(state); continue }
      return { reason: 'error' }
    }
    if (!response.toolCalls.length) return { reason: 'completed' }
    const results = await executeTools(response.toolCalls)
    state = { ...state, messages: [...context, response.message, ...results] }
  }
}

Каждая фича в цикле Агентa — это разработка одной из этих ступеней. Четыре слоя компакции развивают шаг 3 (compress). Паттерн подавления расширяет вызов модели. Лестница эскалации развивает восстановление после ошибок. Stop hooks развивают путь «нет использования инструментов». Начните с этого скелета. Добавляйте каждую детализацию лишь тогда, когда вы столкнётесь с проблемой, которую она решает.


Резюме

Цикл агента — это 1 730 строк одного while(true), который делает всё. Он стримит ответы модели, выполняет инструменты конкурентно, сжимает контекст через четыре слоя, восстанавливается после пяти категорий ошибок, отслеживает бюджеты токенов с детекцией убывающей отдачи, запускает stop hooks, которые могут вернуть модель к работе, управляет предзагрузками памяти и умений, и выдаёт типизированное дискриминированное объединение причин остановки.

Это самый важный файл в системе, потому что это единственный файл, который касается каждой другой подсистемы. Пайплайн контекста подаёт в него. Система инструментов выходит из него. Восстановление оборачивает его. Hooks перехватывают его. Состояние проходит через него. UI рендерится на его основе.

Если вы понимаете query(), вы понимаете Агент. Всё остальное — периферия.