Handoff¶
Quando o contexto está quase cheio, faça com que a própria sessão atual escreva um documento de handoff que uma pessoa consiga ler e editar, e então abra uma nova sessão para assumir. Sem compact. Todo o processo é visível no terminal, e o documento fica em disco, editável a qualquer momento — a sessão que assume lê exatamente esse arquivo.
Handoff não é continuidade
Handoff é trocar de sessão dentro da mesma run; continuidade é retomar a run anterior entre processos, veja Continuidade.
Os dois se encaixam automaticamente, sem fiação extra: a linhagem registra o último session_id daquele passo, e esse é justamente o sucessor — então o próximo wake retoma no sucessor, não na geração queimada.
Que problema resolve¶
O auto-compact que vem no SDK dispara em janela −33k (medido: com janela 200000 o limiar é 167000; o algoritmo de compactação está dentro do binário do harness, não dá para alterar, só dá para alterar se dispara ou não), e o que ele faz é resumir o histórico em um parágrafo. Isso vai na contramão do resto deste framework:
| Quando decide | O que deixa | |
|---|---|---|
spill_guard | No instante em que a ferramenta retorna | Resultado grande vai para spill, sobra uma linha de caminho no contexto |
| Artefatos congelados (brief / objetivo) | No fim daquele passo | Um documento; o passo seguinte só lê ele |
| auto-compact | Só olha para trás quando o contexto encheu | Um resumo escrito pelo próprio modelo |
O que o flower faz do começo ao fim é decidir na hora o que deve ficar. O compact é o único ponto de "remediação a posteriori", e o produto dele tem quatro defeitos:
- É gerado pelo modelo — o que entra no resumo depende do julgamento do modelo naquele momento; você não participa
- É ilegível — foi escrito para o modelo da rodada seguinte, não para uma pessoa
- Não é editável — está dentro do harness, não há arquivo nenhum para você abrir
- Você não sabe o que se perdeu — nem enxerga o que foi descartado, nem consegue dizer antes "esse aqui não descarte"
O handoff traz isso de volta para a mesma abordagem: mais um artefato congelado, com a mesma forma do brief e do objetivo — estruturado, gravado em disco, você abre, edita uma linha e manda continuar. E é exatamente assim que este próprio projeto opera — o HANDOFF.md na raiz do repositório é a mesma coisa, escrita por uma pessoa.
Como usar (código mínimo)¶
A linha de comando já vem com handoff por padrão:
flower # já vem com handoff por padrão
flower --window 200000 # só precisa passar se ele errar a estimativa (padrão: 1 milhão)
flower --no-handoff # desliga — volta ao auto-compact do SDK
Escrevendo código, o handoff é controlado por Runtime(handoff=…), com padrão True:
from flower import HandoffPolicy, Runtime
# padrão: HandoffPolicy(enabled=True, window=default_window(), headroom=50_000, max_generations=8)
rt = Runtime(workspace=".", run_dir="runs", workbench=True)
# janela explícita (é o que mais se ajusta ao trocar de gateway ou de modelo)
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) # desliga, volta ao auto-compact
Runtime.__init__ é inteiramente keyword-only, e workspace é obrigatório. handoff aceita uma instância de HandoffPolicy ou um bool; passar bool equivale a HandoffPolicy(enabled=…).
Handoff ligado = auto-compact desligado à força
Runtime(handoff=True) é o valor padrão, e ele faz o seguinte ao montar cada tentativa: sempre que handoff.enabled e spec.compact is None, troca a spec por CompactPolicy(mode="no_summary") — ou seja, injeta DISABLE_AUTO_COMPACT=1 no subprocesso.
A razão é que, com os dois mecanismos rodando juntos, fica impossível dizer quem causou determinada queda de contexto. O preço é não haver rede de segurança: quando a rodada que escreve o handoff falha, não dá para parar nem para fingir que está tudo bem e esticar até o teto duro, então é obrigatório ter um caminho degradado (veja abaixo).
Para manter o auto-compact como último recurso, é preciso passar explicitamente AgentSpec(compact=CompactPolicy(mode="auto")) — se a spec traz o valor, ele é respeitado, não sobrescrito. Note que isso vence silenciosamente as premissas do lado do handoff.
O que ele faz de fato¶
Momento do disparo: dois caminhos para o handoff¶
Um: o nível chega ao limiar. O critério é _handoff_due: handoff.enabled e não estar na rodada que escreve o handoff e _ctx >= handoff.at e o passo já ter obtido um session_id. _ctx é o tamanho de contexto que a main thread efetivamente viu na última rodada — só olha a main thread; o contexto de um subagent é problema da transcript dele, que se dissolve ao terminar, e não deve forçar handoff na main thread.
Em warn_at sai antes um aviso de aproximação, uma vez por geração, sem poluir a tela.
Dois: a API responde direto "não cabe". Veja a seção is_overflow abaixo.
Os dois caminhos não são limitados por max_attempts e não consomem cota de retentativa (internamente attempt -= 1) — handoff não é falha.
Documento de handoff: cinco seções, só duas obrigatórias¶
Cada seção barra um tipo de erro que quem assume vai cometer:
| Seção | Campo | O que barra |
|---|---|---|
| O que está sendo feito | doing obrigatório | Não saber onde se está |
| O que já foi decidido | decided | Rediscutir o que já foi decidido (com o porquê) |
| Caminhos sem saída | deadends | A seção mais cara — veja abaixo |
| Próximo passo | next obrigatório | Gastar meia hora só para decidir o que fazer |
| Cena | scene | Os caminhos dos arquivos e artefatos-chave. Ponteiros, não conteúdo |
Handoff.missing() só verifica as duas de REQUIRED = ("doing", "next"), e complete() é a negação disso. Exigir "caminhos sem saída" não-vazio forçaria invenção — no começo de uma tarefa essa seção deve mesmo estar vazia. E o veredicto de complete() tem consequência: faltando uma seção obrigatória, o handoff inteiro é substituído por um artefato degradado montado mecanicamente (veja abaixo), o que é muito pior do que um handoff real com uma seção a menos. Por isso as outras três são opcionais — se escritas, ajudam; se não, não impedem o handoff.
Em to_markdown() as seções vazias saem como (空); o cabeçalho de prompt_block() diz explicitamente a quem assume "você está assumindo", para que não volte pedindo contexto a alguém. O campo step só é usado no cabeçalho do documento, não entra no parsing.
Por que "caminhos sem saída" é a seção mais cara¶
Porque é o que custa mais caro para quem assume redescobrir, e é justamente o que quem escreve mais esquece.
O worker tem viés sistemático de otimismo (o goal guard argumenta a mesma coisa): ele escreve o que conseguiu fazer, e esquece de escrever o que tentou e não deu. E é o segundo que é realmente caro — em HT002 foram uma hora de voltas em torno de um problema de compilação; se a conclusão daquela hora não tivesse sido registrada, quem assumisse daria exatamente as mesmas voltas.
Por isso o HANDOFF_PROMPT dedica um parágrafo a esse ponto, incluindo esse custo medido.
Como é um documento de handoff real¶
# Contexto 130.0K/200K · faltam ~20K para o handoff
# Contexto 152.0K/200K —— escrevendo o handoff para trocar de geração
- Fazendo agora adicionando um header de shim para macOS no Makefile, para a macro sigemptyset parar de expandir em erro de sintaxe.
- Já decidido não mexer no código-fonte de negócio —— o usuário definiu esse limite explicitamente, então gerar o shim pelo Makefile.
- Sem saída -D_ANSI_SOURCE desliga outras macros junto; mudar a ordem dos includes não teve efeito.
- Próximo passo rodar make em um clone limpo para verificar que o shim funciona.
<- handoff escrito em ~/proj/.flower/notes/交接-干活.md
<- nova sessão assume, contexto recomeça a partir de 152.0K
Totalmente automático, não para para esperar você — uma run long-horizon não deve travar porque alguém foi almoçar.
O evento é Event("handoff"), e payload["phase"] tem três valores: near (aproximação), writing (escrevendo — escrever o handoff leva uns dez e poucos segundos, e sem esse evento a interface parece travada) e done (troca concluída). O payload de done traz ainda context, window, degraded, path e sections.
Como o limiar é calculado¶
at = max(10_000, window - headroom) # linha de handoff, com piso de 10k
warn_at = max(1_000, at - 20_000) # linha de aviso de aproximação
O piso de 10k em at é indispensável — abaixo disso não dá nem para escrever o handoff.
A escala abaixo usa --window 200000 como exemplo; a janela padrão é 1 milhão:
0--------------------------------------|-----|--------------|
130K 150K 200K
aviso handoff teto duro
Quando window não é passado, default_window() decide pela string do nome do modelo, olhando apenas as variáveis de ambiente ANTHROPIC_MODEL ou ANTHROPIC_DEFAULT_OPUS_MODEL:
| Nome do modelo | É julgado como |
|---|---|
Tem a palavra isolada 1m no nome | 1_000_000 |
Contém haiku no nome | 200_000 |
| Todo o resto, e também quando nenhuma das duas variáveis está setada | 1_000_000 |
Atenção à ordem: 1m casa primeiro, então claude-haiku[1m] é julgado como 1 milhão, não 200 mil.
Por que headroom é 50_000: o auto-compact dispara em janela −33k, e o handoff tem que chegar antes dele; e "escrever o handoff" ainda consome uma rodada. 50k atende às duas coisas ao mesmo tempo.
--window é o botão que mais deve ser ajustado ao trocar de modelo ou de gateway. Do lado do SDK não há como obter o tamanho real da janela, só dá para adivinhar pelo nome. Janela real maior → handoff cedo demais (desperdício, sem erro); menor → não dá tempo, e aí é obrigatório ajustar. Vale citar a medição: o gateway da máquina de desenvolvimento está configurado com claude-opus-5[1m]. Calculando por 200 mil, trocaria de geração a cada 150 mil, quando na verdade ela aguenta até 950 mil — 5x de diferença, e um trabalho long-horizon acabaria picotado.
Com flower -v dá para ver, antes de começar, a configuração de credenciais em vigor (endpoint, nome do modelo, token mascarado deixando só os 4 primeiros caracteres).
is_overflow: transformar erro duro em handoff imediato¶
Esta é a premissa que permite ousar deixar 1 milhão como padrão de default_window().
Se a janela for superestimada, o limiar nunca é alcançado, e o auto-compact está desligado — aí a batida é direto na API. is_overflow(*texts) reconhece esse sinal: prompt is too long, context length exceeded, maximum context length, too many total text bytes, input length and max_tokens exceed, e assim por diante.
Reconhecido o sinal, segue-se o mesmo caminho do handoff, só que o handoff dessa geração é necessariamente degradado — aquela sessão já não consegue rodar "mais uma rodada para escrever o handoff", então usa-se direto o artefato degradado montado mecanicamente e troca-se de sessão para continuar, sem falhar o passo.
Assim o custo de superestimar cai de "o passo falha" para "o handoff dessa geração é degradado".
is_overflow é uma função de nível de módulo, não um método de Handoff, e recebe argumentos variádicos.
Quando o handoff não sai: degradar, não parar¶
A rodada que escreve o handoff também pode falhar — rede caiu, modelo surtou, o parsing veio sem uma seção obrigatória. Como o auto-compact já está desligado, não há rede de segurança, e parar aqui equivale a bater na janela.
O que se faz: montar mecanicamente um handoff incompleto com o que já se sabe, marcar em doing o [降级:交接没写成] (constante DEGRADED), colocar em scene os primeiros 1200 caracteres do prompt original, e trocar de geração normalmente. Quem assume é avisado explicitamente de que recebeu algo incompleto e de que deve ir olhar a cena por conta própria. Ao mesmo tempo, StepResult.errors ganha uma entrada "交接降级(…)", e o motivo fica consultável em manifest.json.
Isso corresponde à função de nível de módulo degraded(step, prompt, *, why=""); Handoff.degraded é uma property somente-leitura, que verifica se aquela marca está em doing.
Um handoff incompleto é muito melhor do que bater na janela.
A rodada que escreve o handoff tem ainda dois arranjos deliberados: roda com max_budget_usd=None — o handoff tem que sair, não pode travar no orçamento; e on_event=None — essa rodada não emite para a UI.
Uma mina: a rodada que escreve o handoff precisa ser isenta do limiar¶
O handoff é escrito depois de cruzada a linha — nesse momento o nível já está acima do limiar por definição. Sem a isenção, a primeira mensagem da rodada do handoff julga de novo "hora de trocar de geração", e ela é interrompida sem escrever uma palavra, produzindo artefato degradado em toda geração, e com tudo parecendo normal (o caminho degradado funciona muito bem).
Isso já pegou de verdade: na primeira execução real de tests/handoff_live.py, as duas gerações de handoff vieram degradadas. Os testes offline não pegaram — lá o _attempt inteiro era substituído, e o dublê não rodava esse critério. Agora o critério foi promovido a Runtime._handoff_due(), e o offline o verifica diretamente.
Uma trava contra disparada¶
max_generations=8.
window configurada pequena demais gera handoff infinito e queima dinheiro
O perigo é: o limiar ficar abaixo do piso de partida daquele papel (no coordenador, medido em cerca de 34k, só o system prompt mais o índice do workbench já consomem isso); aí cada sessão nova cruza a linha logo na primeira fala → escreve handoff, troca de geração, cruza de novo, sem fim. E como handoff não consome cota de retentativa — o que é intencional —, a única trava é max_generations=8.
Uma run longa normal não chega a 8 gerações; se chegar, é quase certo que window está pequena demais — ao atingir o limite, a mensagem de erro diz exatamente isso ("o limiar provavelmente está abaixo do piso de partida deste papel; aumente window, ou use --no-handoff").
O processo completo de um handoff¶
trabalhando (sessão A)
| contexto da main thread cruza o limiar <- só olha a main thread. O contexto do subagent é
| problema da transcript dele, se dissolve ao terminar,
| e não deve forçar handoff na sessão principal
|- corta na fronteira de mensagem <- mesma lógica de interromper com Ctrl-C: cortar limpo,
| sem rasgar o estado (mesmo preço: subagents em voo
| se perdem. A folga de 50k existe para isso)
|- roda mais uma vez na mesma sessão: escrever o handoff
| por que é ela mesma que escreve —— só ela tem aquele contexto. Qualquer outro teria que ler tudo antes, o que anula a troca
|- congela em <workbench>/notes/交接-<nome-do-passo>.md, e a geração anterior vai para notes/archive/交接/
|- nova sessão (resume=None, fork=False), prompt = prompt_block() do documento de handoff
trabalhando (sessão B) continua
HANDOFF_PROMPT é o prompt que faz a sessão atual escrever o handoff, com os dois placeholders {used} e {window}. Não é um papel novo — só esta sessão tem aquele contexto.
Handoff não conta como retentativa; como fica a contabilidade¶
| Campo | Como muda no handoff |
|---|---|
attempts | Não sobe — ele conta tentativas que falharam |
retired[] | Os session_id queimados neste passo ficam aqui, em ordem |
session_id | É sempre o último sucessor, não o queimado |
context | Tamanho de contexto que a main thread efetivamente viu na última rodada |
cost_usd / num_turns | Acumulam ao longo de retentativas e handoffs |
Todos esses campos vão para o manifest.json, e depois dá para reconstruir integralmente "quantas gerações este passo queimou e quanto cada uma custou".
Onde o handoff é gravado¶
<workbench>/notes/交接-<nome do passo sem caracteres inválidos>.md; a geração anterior, se existir, é movida para notes/archive/交接/<nome-do-passo>-<timestamp>.md.
Sem workbench não há gravação — nesse caso _handoff_path retorna None, o documento ainda assim é entregue ao sucessor via prompt e o handoff acontece normalmente; só que depois ninguém consegue achar aquele arquivo. Para poder consultar depois, ligue o workbench (Runtime(workbench=True), ou deixe o próprio workflow montar um).
Quando não usar¶
- Você quer mesmo o compact.
flower --no-handoff, ouRuntime(handoff=False). O handoff desliga o auto-compact junto; se não quer esse efeito colateral, não ligue. - Você quer os dois ao mesmo tempo. Passar explicitamente
AgentSpec(compact=CompactPolicy(mode="auto"))preserva o auto-compact, mas depois disso fica impossível dizer quem causou determinada queda de contexto, e o diagnóstico piora. Confie no handoff ou confie no compact, não nos dois. - Tarefas curtas, de uma rodada só. O handoff nunca vai disparar, e configurá-lo não faz sentido — mas lembre que o
Runtimecomhandoff=Truepor padrão continua desligando o auto-compact. - Sem workbench e esperando ler o handoff depois. Ligue o workbench antes, senão o documento só existiu no contexto daquela run.
- Começar uma run longa com
windowainda desajustada. Quando a janela real é menor que o padrão, as primeiras gerações produzem só artefatos degradados, e o degradado é justamente o tipo mais inútil de handoff. Acerte com--windowantes, ou rode algo curto para ver o nome do modelo no-v. - Tratar o handoff como toda a governança de contexto. Ele é a última linha. As camadas que cortam na hora (spill, trim, prune) são mais baratas, veja Economia de contexto.
Sintoma × botão a girar¶
| Sintoma | Qual botão |
|---|---|
| Handoff frequente demais, o trabalho vive sendo interrompido | Ajuste --window para a janela real do modelo (-v mostra o nome do modelo em vigor) |
| Handoff logo na largada, com aviso de "piso de partida" | Idem; window está pequena demais |
| Handoff sempre degradado | Veja errors em runs/manifest.json, o motivo da degradação está lá |
| Quem assume repete o trabalho da geração anterior | A seção "caminhos sem saída" ficou rala. Dá para editar aquele arquivo direto |
| Quer ler o handoff depois e não acha o arquivo | Workbench desligado. O handoff não foi gravado, só passou pelo prompt |
| Só quer usar o compact | --no-handoff |
O que ler em seguida¶
- Continuidade — retomar a run anterior entre processos; é a outra direção da mesma coisa desta página
- Economia de contexto — as camadas que cortam na hora
- Goal guard — o argumento do "worker tem viés sistemático de otimismo"
- API Python —
HandoffPolicy,Handoff,CompactPolicy,default_window,StepResult - Linha de comando —
--window,--no-handoff - Código-fonte:
core/handoff.py·core/agent.py·core/runtime.py