Retomar uma sessão compactada diz tudo duas vezes: por dentro do summary carrier


Você comprime uma conversa longa, sai do Hermes e volta na manhã seguinte com --resume. A primeira metade parece certa. Em algum ponto perto da compaction, uma resposta do modelo começa a se repetir — não uma frase, a passagem inteira, palavra por palavra, duas vezes. O scrollback dobra de tamanho e você começa a suspeitar que o contexto estourou e que a mensagem realmente está gravada duas vezes. Então conta as linhas no state.db e há menos do que esperava.

Essa duplicação foi corrigida em 20 de outubro de 2026 (PR #142187). Vale um post próprio porque expõe uma estrutura que você normalmente nunca vê: o summary carrier.

A compaction envolve essa resposta uma vez

A compaction in-place não apaga mensagens antigas. Ela mantém uma cauda protegida — o turno mais recente, intacto — e a emparelha com um resumo de compaction dentro de uma nova linha. Essa nova linha é o carrier. Ele herda o message_uid do original, mas não o conteúdo: seu texto é “o turno anterior, depois o resumo”.

O design é sólido. O uid mantém estável a pergunta “qual mensagem é esta” ao longo da compaction, e rewind, export e dedupe se apoiam nele. O problema estava na camada de exibição.

Por que renderizava duas vezes

Desde a #140971, retomar uma conversa comprimida mostra o histórico inteiro — exatamente o que os usuários pediram, em vez de só o resumo. Então a leitura de exibição devolve dois conjuntos de linhas: os originais arquivados de antes da compaction e o novo carrier. O carrier compartilha o message_uid, mas envolve o resumo no próprio corpo, e a camada de exibição não conseguia perceber que era um contêiner para a mesma passagem. Renderizava as duas linhas, então o texto original aparecia uma vez e a cópia dentro do carrier aparecia de novo.

O teste end-to-end oficial da TUI colocou número nisso: o E2E de resume/compress reportou 200 words rendered more than once em /resume, --resume e /compress. Esse tipo de asserção vale mais que um screenshot — ela afirma, sem rodeio, que 200 palavras foram desenhadas mais de uma vez.

A correção: o carrier empresta a identidade de exibição do original

O patch não mexe no armazenamento. Ele unifica o cálculo da identidade de exibição: _dedupe_display_generations em hermes_state_messages.py ganha uma regra — um summary carrier compartilha a identidade de exibição da linha anterior com o mesmo message_uid, e a cópia mais nova vence, então a cópia antiga deixa de renderizar separadamente.

A docstring da própria função é explícita: colapsar gerações de compaction para que cada mensagem lógica apareça uma vez (a cauda protegida é copiada em toda geração — mesmo role, conteúdo e timestamp, active e id diferentes); prefira a cópia viva, depois a mais nova. E é uma definição compartilhada por toda projeção de exibição, não uma implementação separada por superfície.

A correção é deliberadamente estreita: só o carrier. Um stub de tool podado mantém a saída completa arquivada visível, e test_display_dedupe_pruned_tool_payload fixa isso — um match por uid anterior, sem escopo, quebrava esse caso.

Um commit companheiro (c8aa61b7883) aplicou a mesma regra a exports e à lista de transcrições, então hermes sessions export também não emite mais uma resposta carregada duas vezes.

Algum dado se perdeu?

Não. O bug inteiro era de exibição. A validação usou o state.db do próprio E2E que falhava: antes da correção, a leitura de exibição devolvia 15 linhas, com repetições; depois, 14 linhas, sem nenhuma. Menos linhas é a prova de que a cópia extra era artefato de renderização, não dado gravado.

Uma autoverificação útil: quando suspeitar de duplicação, compare o número de linhas do banco com o número de mensagens que a interface mostra. Se o banco está normal e a visão dobra, é renderização. Se ambos dobram, algo realmente gravou duas vezes. A rodada de testes mais ampla por trás do patch (tests/hermes_state, RPCs de resume e recovery do gateway, tests/tui_gateway) reportou 4328 passados, 0 falhas.

Um problema vizinho continua aberto

Uma issue na mesma área segue aberta: quando a compaction funde uma mensagem do usuário num aviso de “troca de modelo” anterior, essa mensagem do usuário desaparece da transcrição. Não é o mesmo bug — um mostra uma cópia a mais, o outro mostra uma mensagem a menos — e esta correção não o absorveu. Se uma mensagem do usuário sumir depois da compaction, não aplique a conclusão acima.

Para entender compaction e orçamento de contexto como é devido, leia como a compaction se completa numa única chamada e a reconstrução do prompt de compaction. Comandos de histórico de sessão, export e resume estão na referência de hermes sessions, e se o seu objetivo é manter sessões longas dentro de um orçamento, o texto relevante é otimização de tokens de contexto.

Já dá para usar hoje?

Esta correção é um dos itens que desbloqueiam a v0.22.3, então ela vive só na main e não está em release publicada (a mais recente é a v0.22.2). Quando você estiver num build que a contenha, /resume, --resume e /compress devem mostrar cada resposta carregada uma vez; se ainda repetir, confirme a versão primeiro e depois olhe para a camada de exibição.

Resumo rápido

O summary carrier é o contêiner que a compaction deixa para trás: herda o uid do original, mas seu corpo é “turno anterior mais resumo”. Uma camada de exibição que identifica mensagens só pelo uid, sem reconhecer carriers, desenha a mesma passagem duas vezes. A correção empresta ao carrier a identidade de exibição do original e limita a regra aos carriers, então saída podada de tool continua mostrando o conteúdo arquivado. Guarde a regra de diagnóstico: visão dobrada com banco normal é problema de exibição; os dois dobrados é problema de dado.