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=None — handoff обязан быть написан, он не может застрять на бюджете; и с 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 |
Что читать дальше¶
- Преемственность — подхват предыдущего прогона между процессами, та же самая вещь с другой стороны
- Экономика контекста — слои, которые режут на месте
- Страж цели — доказательство тезиса «у исполнителя систематический уклон в оптимизм»
- Python API —
HandoffPolicy,Handoff,CompactPolicy,default_window,StepResult - Командная строка —
--window,--no-handoff - Исходники:
core/handoff.py·core/agent.py·core/runtime.py