Перейти к содержанию

Handoff

Когда контекст почти заполнен, текущая сессия сама пишет handoff-документ, который человек может прочитать и поправить, а затем эстафету принимает новая сессия. Без compact. Весь процесс виден в терминале, документ лежит на диске, править его можно в любой момент — принимающая сессия читает именно этот файл.

Handoff — это не преемственность

Handoff — это смена сессии внутри одного прогона; преемственность — это подхват предыдущего прогона между процессами, см. Преемственность.

Обе вещи сцепляются автоматически, дополнительной проводки не нужно: родословная хранит последний session_id этого шага, а это и есть преемник — поэтому следующее пробуждение подхватывает преемника, а не сожжённое поколение.

Какую задачу решает

Встроенный в SDK auto-compact срабатывает на отметке окно −33k (замерено: при окне 200000 порог равен 167000; сам алгоритм сжатия зашит в бинарник harness, изменить его нельзя, можно повлиять только на факт срабатывания), а делает он вот что — сворачивает историю в один абзац. Он идёт вразрез со всем остальным в этом фреймворке:

Когда принимается решение Что остаётся
spill_guard В момент возврата инструмента Большой результат уходит на диск, в контексте остаётся одна строка с путём
Замороженные артефакты (бриф / цель) В конце шага Один документ, следующий шаг читает только его
auto-compact Только когда контекст уже заполнился Резюме, написанное самой моделью

flower от начала и до конца занимается тем, что решает на месте, что стоит оставить. Compact — единственное место, где идёт «починка задним числом», и у его продукта четыре изъяна:

  • Сгенерирован моделью — что попадёт в резюме, решает модель в тот момент, вы в этом не участвуете
  • Нечитаем — он написан для модели следующего раунда, а не для человека
  • Неправим — он внутри harness, нет файла, который можно открыть
  • Что потеряно — неизвестно — не видно, что именно выброшено, и нельзя заранее сказать «вот это не выбрасывай»

Handoff возвращает всё это в общую схему: ещё один замороженный артефакт, той же формы, что бриф и цель — структурированный, лежащий на диске, который можно открыть, поправить строку и запустить дальше. И ровно так делает сам этот проект — HANDOFF.md в корне репозитория — это та же самая вещь, написанная человеком.

Как пользоваться (минимальный код)

В командной строке handoff включён по умолчанию:

flower                       # handoff включён по умолчанию
flower --window 200000       # указывать нужно, только если определилось неверно (по умолчанию 1 000 000)
flower --no-handoff          # выключить — возврат к встроенному в SDK auto-compact

В своём коде handoff управляется через Runtime(handoff=…), по умолчанию True:

from flower import HandoffPolicy, Runtime

# по умолчанию: HandoffPolicy(enabled=True, window=default_window(), headroom=50_000, max_generations=8)
rt = Runtime(workspace=".", run_dir="runs", workbench=True)

# явная настройка окна (первое, что стоит крутить при смене шлюза или модели)
rt = Runtime(
    workspace=".",
    run_dir="runs",
    workbench=True,
    handoff=HandoffPolicy(window=200_000, headroom=50_000, max_generations=8),
)

rt = Runtime(workspace=".", run_dir="runs", handoff=False)   # выключить, возврат к auto-compact

Все параметры Runtime.__init__ — keyword-only, workspace обязателен. handoff принимает экземпляр HandoffPolicy либо bool; bool эквивалентен HandoffPolicy(enabled=…).

Включённый handoff = auto-compact принудительно выключен

Runtime(handoff=True) — это значение по умолчанию, и при сборке каждой попытки происходит следующее: если handoff.enabled и spec.compact is None, spec подменяется на CompactPolicy(mode="no_summary") — то есть в дочерний процесс инжектится DISABLE_AUTO_COMPACT=1.

Причина в том, что при одновременной работе двух механизмов нельзя сказать, кто именно уронил контекст в очередной раз. Плата за это — отсутствие страховочной сетки: когда раунд записи handoff падает, остановиться нельзя, и делать вид, что всё в порядке, дотягивая до жёсткого предела, тоже нельзя, поэтому обязателен путь деградации (см. ниже).

Чтобы сохранить auto-compact как подстраховку, нужно явно задать AgentSpec(compact=CompactPolicy(mode="auto")) — если spec задан явно, он уважается и не перезаписывается. Учтите, что это молча ломает предпосылки, на которых стоит handoff.

Что он делает на самом деле

Момент срабатывания: два пути к handoff

Первый — уровень достиг порога. Критерий — _handoff_due: handoff.enabled, не идёт раунд записи handoff, _ctx >= handoff.at и на этом шаге уже получен session_id. _ctx — это размер контекста, реально увиденный главным потоком в последнем раунде — учитывается только главный поток, контекст субагента — дело его собственного transcript, он рассеивается после завершения и не должен вынуждать главный поток к смене поколения.

На отметке warn_at сначала уходит предупреждение о приближении — по одному на поколение, экран не засоряется.

Второй — API прямо говорит «не влезает». См. раздел про is_overflow ниже.

Оба пути не ограничены max_attempts и не расходуют лимит ретраев (внутри attempt -= 1) — handoff не является провалом.

Handoff-документ: пять разделов, обязательны только два

Каждый раздел закрывает одну из ошибок, которые совершает принимающий:

Раздел Поле Что закрывает
Что делается сейчас doing обязательно Непонимание, где ты находишься
Что уже решено decided Повторное обсуждение решённого (писать нужно почему)
Тупики deadends Самый дорогой раздел — см. ниже
Следующий шаг next обязательно Полчаса на выяснение, чем вообще заняться
Обстановка scene Пути к ключевым файлам и артефактам. Указатели, не содержимое

Handoff.missing() проверяет только два раздела — REQUIRED = ("doing", "next"), а complete() — это его отрицание. Жёсткое требование непустых «тупиков» вынудило бы их выдумывать — в начале задачи этот раздел и должен быть пустым. Кроме того, результат complete() имеет последствия: при отсутствии обязательного раздела весь handoff заменяется механически собранным деградированным артефактом (см. ниже), а это гораздо хуже настоящего handoff с одним ненаписанным разделом. Поэтому остальные три раздела опциональны — написаны, значит полезны; не написаны — handoff всё равно состоится.

В to_markdown() пустой раздел записывается как (空); заголовок prompt_block() прямо сообщает принимающему, что он «принимает эстафету», чтобы он не пошёл выпрашивать контекст у человека. Поле step используется только в шапке документа и в разборе не участвует.

Почему «тупики» — самый дорогой раздел

Потому что это то, что принимающему дороже всего открывать заново, и именно это пишущий чаще всего забывает.

У исполнителя есть систематический уклон в оптимизм (Страж цели доказывает ровно то же самое): он напишет, что у него получилось, и забудет написать, что он пробовал и что не сработало. А дорого стоит именно второе — в HT002 на проблему компиляции ушёл час кружения; если бы вывод этого часа не был записан, принимающий накрутил бы ровно те же круги заново.

Поэтому в HANDOFF_PROMPT есть отдельный абзац именно про это, с указанием измеренной цены.

Как выглядит настоящий handoff-документ

# 上下文 130.0K/200K · 还有约 20K 到换代

# 上下文 152.0K/200K —— 写交接准备换代
  - 现在在做  在给 Makefile 加 macOS 垫片头,让 sigemptyset 宏不再展开成语法错误。
  - 已定的事  不改业务源码 —— 用户明确说过边界,所以走 Makefile 生成 shim 这条路。
  - 走不通的  -D_ANSI_SOURCE 会把别的宏一起关掉;改 include 顺序无效。
  - 下一步    在干净 clone 上跑一次 make 验证 shim 成立。
<- 交接写在 ~/proj/.flower/notes/交接-干活.md
<- 新会话接手,上下文从 152.0K 重新开始

Полностью автоматически, никто не ждёт вас — long-horizon прогон не должен вставать оттого, что человек ушёл обедать.

Событие — Event("handoff"), у payload["phase"] три значения: near (приближение), writing (идёт запись — handoff пишется десяток-другой секунд, и без этого события интерфейс выглядит зависшим), done (смена завершена). В payload у done также есть context, window, degraded, path, sections.

Как считается порог

at      = max(10_000, window - headroom)   # линия handoff, нижняя граница 10k
warn_at = max(1_000, at - 20_000)          # линия предупреждения о приближении

Нижняя граница at в 10k обязательна — ниже уже не хватит даже на запись handoff.

Шкала ниже нарисована для примера с --window 200000, окно по умолчанию — 1 000 000:

  0--------------------------------------|-----|--------------|
                                       130K  150K           200K
                                     предупр. handoff   жёсткий предел

Если window не задан, его определяет default_window() по строке имени модели, глядя только на две переменные окружения — ANTHROPIC_MODEL и ANTHROPIC_DEFAULT_OPUS_MODEL:

Имя модели Определяется как
В имени есть отдельное слово 1m 1_000_000
Имя содержит haiku 200_000
Всё остальное, а также случай, когда обе переменные не заданы 1_000_000

Обратите внимание на порядок: 1m проверяется первым, поэтому claude-haiku[1m] будет определён как 1 000 000, а не 200 000.

Почему headroom равен 50_000: auto-compact срабатывает на отметке окно −33k, и handoff обязан успеть раньше; плюс сама «запись handoff» — это ещё один раунд. 50k покрывает оба требования.

--window — первое, что нужно крутить при смене модели или шлюза. Со стороны SDK достоверный размер окна получить нельзя, остаётся угадывать по имени. Реальное окно больше → handoff случается слишком рано (расточительно, но не ошибка); меньше → не успевает, и это надо править. Замер, который стоит упомянуть: на машине разработки шлюз настроен на claude-opus-5[1m]. Если бы считали по 200 000, поколение менялось бы каждые 150 000, хотя реально он дотягивает до 950 000 — разница в 5 раз, long-horizon работа была бы искрошена в мелочь.

Через flower -v перед запуском можно увидеть действующую конфигурацию доступа (эндпоинт, имя модели, токен замаскирован до первых 4 символов).

is_overflow: превращение жёсткой ошибки в handoff на месте

Это предпосылка того, что default_window() вообще позволяет себе значение по умолчанию в 1 000 000.

Если окно определено с завышением, порог никогда не будет достигнут, а auto-compact выключен — значит, будет жёсткий удар об API. is_overflow(*texts) распознаёт такие сигналы: prompt is too long, context length exceeded, maximum context length, too many total text bytes, input length and max_tokens exceed и подобные.

После распознавания идёт тот же самый путь handoff, только handoff этого поколения неизбежно деградированный — та сессия уже не потянет «ещё один раунд на запись handoff», поэтому берётся механически собранный деградированный артефакт, работа продолжается в новой сессии, и шаг не падает.

Тем самым цена завышенной оценки падает с «шаг провален» до «handoff этого поколения деградированный».

is_overflowфункция уровня модуля, а не метод Handoff, и принимает переменное число аргументов.

Когда handoff не удаётся написать: деградация, а не остановка

Раунд записи handoff тоже может упасть — оборвалась сеть, модель ушла в разнос, в разобранном результате нет обязательного раздела. Поскольку auto-compact уже выключен, подстраховки нет, и остановка здесь равносильна удару об окно.

Что делается: из того, что известно, механически собирается неполный handoff, в doing ставится метка [降级:交接没写成] (константа DEGRADED), в scene кладутся первые 1200 символов исходной задачи, и смена поколения происходит как обычно. Принимающему прямо сказано, что полученное неполно и что стоит сходить и посмотреть на месте. Одновременно в StepResult.errors добавляется запись «交接降级(…)», а причину можно найти в manifest.json.

Этому соответствует функция уровня модуля degraded(step, prompt, *, why=""); Handoff.degraded — read-only property, которое проверяет наличие этой метки в doing.

Неполный handoff несравнимо лучше удара об окно.

В раунде записи handoff есть ещё два намеренных решения: он идёт с max_budget_usd=Nonehandoff обязан быть написан, он не может застрять на бюджете; и с on_event=None — этот раунд не льётся в UI.

Одна мина: раунд записи handoff обязан быть освобождён от порога

Handoff пишется уже после пересечения линии — уровень к этому моменту и так висит выше порога. Без освобождения первое же сообщение раунда записи снова оценивается как «пора менять поколение», handoff прерывается, не написав ни слова, и каждое поколение выдаёт деградированный артефакт, причём выглядит всё исправно (путь деградации работает отлично).

На этом уже наступали: в tests/handoff_live.py при первом настоящем прогоне оба поколения handoff оказались деградированными. Офлайн-тесты этого не поймали — там _attempt подменялся целиком, и подделка этот критерий не выполняла. Сейчас критерий вынесен в Runtime._handoff_due(), и офлайн проверяется напрямую.

Заслонка против разгона

max_generations=8.

Заниженный window приведёт к бесконечной смене поколений и сжиганию денег

Опасность в следующем: порог оказывается ниже стартового пола этой роликоординатора замерено около 34k — столько занимают уже системный промпт и индекс верстака), и тогда каждая новая сессия пересекает линию с первого же слова → пишет handoff, меняет поколение, снова пересекает линию, и так без конца. А handoff намеренно не расходует лимит ретраев, так что единственная заслонка — это max_generations=8.

В нормальном длинном прогоне до 8 поколений дело не доходит; если упёрлись — почти наверняка занижен window, и при достижении предела сообщение об ошибке прямо это и говорит («порог, скорее всего, ниже стартового пола этой роли; увеличьте window или используйте --no-handoff»).

Полный ход одной смены поколения

работа (session A)
  |  контекст главного потока перешёл порог  <- учитывается только главный поток. Контекст субагента — дело его
  |                                             собственного transcript, он рассеивается после завершения и не должен
  |                                             вынуждать главную сессию менять поколение
  |- разрыв на границе сообщения             <- та же логика, что у прерывания Ctrl-C: разорвать чисто, не разрывая состояние
  |                                             (и цена та же: субагенты в полёте теряются. Запас в 50k оставлен ровно на это)
  |- ещё один раунд в той же сессии: запись handoff
  |     почему пишет он сам — только у него есть этот контекст. Любой другой сначала должен был бы его прочитать, а это обнуляет смысл смены
  |- заморозка в <工作台>/notes/交接-<步骤名>.md, предыдущее поколение перемещается в notes/archive/交接/
  |- новая сессия (resume=None, fork=False), prompt = prompt_block() handoff-документа
работа (session B) продолжается

HANDOFF_PROMPT — это тот промпт, которым текущая сессия побуждается написать handoff, он содержит два плейсхолдера: {used} и {window}. Это не новая роль — этот контекст есть только у текущей сессии.

Handoff не считается ретраем: как ведётся учёт

Поле Что происходит при handoff
attempts Не растёт — оно считает неудачные попытки
retired[] Сюда по порядку пишутся сожжённые на этом шаге session_id
session_id Всегда последний принявший, а не сожжённый
context Размер контекста, реально увиденный главным потоком в последнем раунде
cost_usd / num_turns Накапливаются через ретраи и смены поколений

Все эти поля попадают в manifest.json, так что задним числом можно полностью восстановить, сколько поколений сожжено на шаге и сколько стоило каждое.

Куда ложится handoff

<工作台>/notes/交接-<步骤名去掉非法字符>.md; уже существующее предыдущее поколение перемещается в notes/archive/交接/<步骤名>-<时间戳>.md.

Без верстака на диск ничего не пишется — тогда _handoff_path возвращает None, документ всё равно передаётся принимающему через промпт, смена поколения идёт как обычно, просто человек потом не сможет найти этот файл. Чтобы можно было найти, включите верстак (Runtime(workbench=True) либо workflow подключает его сам).

Когда этим пользоваться не надо

  • Хочется именно compact. flower --no-handoff либо Runtime(handoff=False). Handoff заодно выключает auto-compact; не нужен этот побочный эффект — не включайте его.
  • Хочется, чтобы работали оба механизма. Явный AgentSpec(compact=CompactPolicy(mode="auto")) сохранит auto-compact, но после этого нельзя будет сказать, кто именно уронил контекст в очередной раз, и разбор станет труднее. Либо доверяйте handoff, либо compact, но не обоим сразу.
  • Короткие задачи, работа в один раунд. Handoff никогда не сработает, настраивать его бессмысленно — но помните, что Runtime по умолчанию идёт с handoff=True и всё равно выключает auto-compact.
  • Нет верстака, но есть расчёт прочитать handoff потом. Сначала включите верстак, иначе документ существовал только в контексте того прогона.
  • Начинать длинный прогон, не подобрав window. Если реальное окно меньше значения по умолчанию, первые поколения handoff будут сплошь деградированными, а деградированный handoff — самый бесполезный вид handoff. Сначала подберите --window либо сделайте короткий прогон и посмотрите имя модели в -v.
  • Считать handoff всей стратегией управления контекстом. Это последний рубеж. Слои, которые режут на месте (сброс на диск, усечение, отсечение), дешевле, см. Экономику контекста.

Симптомы: какую ручку крутить

Симптом Что крутить
Слишком частые смены поколений, работа постоянно прерывается Выставить --window под реальное окно модели (действующее имя модели видно в -v)
Смена поколения сразу на старте, да ещё с упоминанием «стартового пола» То же самое, window занижен
Handoff всегда деградированный Смотрите errors в runs/manifest.json, там записана причина деградации
Принимающий постоянно повторяет работу предыдущего поколения Раздел «тупики» в handoff написан слишком скудно. Этот файл можно править напрямую
Хочется прочитать handoff потом, но файла нет Верстак не включён. Handoff не пишется на диск, он ушёл только через промпт
Хочется именно compact --no-handoff

Что читать дальше