Seu plugin finalmente percebe quando uma sessão é compactada ou apagada

Você mantém um plugin de memória que injeta as convenções de código do seu time e três endpoints da API em cada turno. Por vinte minutos tudo funciona lindamente. Aí o Hermes decide que a conversa está longa demais e a compacta — cada mensagem do começo dobrada dentro de um único parágrafo de resumo, convenções incluídas. Daquele momento em diante o modelo segue perguntando coisas que você já respondeu, e seu plugin não faz a menor ideia de que algo aconteceu.
Existe uma versão mais discreta do mesmo buraco: você guarda um pequeno arquivo de índice por sessão em disco. Um colega apaga quarenta sessões pelo app Desktop, e seu índice continua exatamente tão gordo quanto antes — cheio de entradas que ninguém nunca mais vai consultar.
Os dois problemas têm a mesma raiz: plugins enxergavam “uma ferramenta foi chamada” e “um turno começou”, mas não “a transcrição foi reescrita” nem “esta sessão não existe mais”. Dois patches mesclados em 18 de outubro fecham a lacuna — um hook post_compaction novinho em folha e um on_session_delete que cobre todos os pontos de entrada de exclusão.
Por que os plugins não viam isso antes
O sistema de plugins já tinha um conjunto de ciclo de vida: on_session_start, on_session_end, on_session_finalize, on_session_reset. Esses marcam momentos no tempo, não reescritas de conteúdo.
A compactação é uma ação interna. Ela pode acontecer no meio do turno quando o contexto enche, logo depois que um resultado gigante de ferramenta chega, quando o agente poda proativamente saídas antigas de ferramenta — ou quando uma pessoa digita /compress. Para um plugin, tudo isso era invisível. A exclusão de sessão é a mesma história pelo outro lado: hermes sessions delete, exclusões individuais e em massa pelo Desktop, /exit --delete, a varredura de sessões vazias, a poda por retenção, a manutenção de linhas fantasma. Tudo termina nos mesmos métodos de banco de dados, mas os plugins não recebiam callback nenhum.
Então quem escreve plugin fez o que se faz quando não há sinal: ficar consultando o session store em loop, adivinhar pelo número de tokens se uma compactação rolou, varrer diretórios atrás de órfãos. Lento no melhor caso, errado no pior.
post_compaction: devolva o que o resumo engoliu
O registro é igual ao de qualquer outro hook:
def register(ctx):
ctx.register_hook("post_compaction", on_compaction)
def on_compaction(session_id, kind, reason, **kwargs):
# marca a sessão e reinjeta no próximo pre_llm_call
mark_session_for_reinjection(session_id, kwargs.get("messages_removed"))
O que o callback recebe:
| Campo | Significado |
|---|---|
session_id |
A sessão que agora guarda a transcrição compactada |
old_session_id |
A sessão em que tudo começou, quando a compactação rotacionou; string vazia quando a compactação foi feita no lugar |
kind |
summarize (compactação completa), micro_summarize, prune_tool_results, provider_native (Codex app-server) |
reason |
O que disparou: manual (/compress), pre_api, post_tool, overflow, gateway_hygiene, between_turns, proactive_prune, provider, unknown |
before_tokens / after_tokens |
Estimativas aproximadas de tokens, None quando desconhecidas |
messages_removed |
Quantas mensagens saíram, None quando desconhecido |
O ponto importante é que ele dispara uma vez para cada compactação que de fato acontece, em todos os caminhos: backends CLI, TUI e Desktop, agentes de gateway, sessões de cron, o /compress do gateway, a compactação nativa do Codex, a micro-compactação e a poda proativa de resultados de ferramenta. Tentativas que não mudaram nada — um resumo abortado, um bloqueado por cooldown, um recusado porque aumentaria a transcrição, um /compress vazio — não disparam nada, então você nunca precisa ficar na dúvida se uma “compactação” foi ou não um no-op.
Um padrão que funciona de verdade: anote que esta sessão precisa de um refresh e reinjete no próximo pre_llm_call. O hook em si é um observador — o valor de retorno dele é ignorado e você não consegue reescrever a conversa de dentro dele. Isso é proposital: a compactação já foi efetivada, e mexer nela no meio do caminho quebraria a consistência.
_pending = {}
def on_compaction(session_id, kind, reason, messages_removed=None, **kw):
if kind in ("summarize", "micro_summarize"):
_pending[session_id] = {"kind": kind, "removed": messages_removed}
def inject(session_id, conversation_history, **kw):
if session_id in _pending:
_pending.pop(session_id)
return "Regras do projeto: todo endpoint novo precisa de anotações OpenAPI; migrações só via alembic."
return None
on_session_delete: acerte suas próprias contas
O segundo hook cuida da limpeza. Ele dispara uma vez, depois que a exclusão ou o arquivamento deliberado é efetivado:
| Parâmetro | Significado |
|---|---|
session_id |
A sessão alvo da operação |
reason |
delete, archive, empty_sweep, prune, ghost_prune, profile_repair |
profile |
O profile dono do store (default para o home raiz) |
deleted_ids |
Todas as linhas removidas (filhos em cascata, elos da cadeia de compressão, linhas de operações em massa) quando mais de uma saiu |
sessions_dir |
O diretório sessions/ do profile quando quem chamou tinha um |
def on_delete(session_id, reason, profile=None, deleted_ids=None, sessions_dir=None, **kw):
ids = deleted_ids or [session_id]
for sid in ids:
drop_sidecar(sid) # seu cache / índice por sessão
log.info("cleaned %d session rows (%s)", len(ids), reason)
def register(ctx):
ctx.register_hook("on_session_delete", on_delete)
Dois detalhes desse patch passam fácil e vale conhecer.
Primeiro, o Desktop nunca disparava antes. Exclusões individuais e arquivamentos no Desktop e no dashboard não passam pelo SessionDB.delete_session — eles passam por um caminho de recibo de runtime-session, então a ligação antiga ficava muda ali. Esse caminho agora dispara também, e uma repetição exata reproduz o recibo sem emitir um segundo evento.
Segundo, arquivar conta. Arquivar significa que o usuário escondeu a sessão; a transcrição continua lá e pode ser desarquivada. Para um plugin isso normalmente quer dizer “esta thread acabou” — se você estava rastreando uma tarefa, o usuário arquivar a conversa é um bom momento para encerrá-la. Só a virada real de não-arquivada para arquivada dispara, então varreduras de auto-arquivamento por inatividade e adoção de profile ficam em silêncio.
Uma terceira coisa corrigida na mesma janela: slash commands sabem em que sessão estão
Uma mudança relacionada merece menção. Um slash command de plugin registrado com ctx.register_command recebia apenas a string crua de argumentos — nenhuma ideia de quem executou, em qual plataforma, em qual sessão. Agora um handler que declara essas palavras-chave as recebe:
def _handle_whoami(raw_args, *, session_id="", platform="", source=None):
user = source.user_id if source is not None else "local user"
return f"{user} on {platform}, session {session_id or '(none yet)'}"
def register(ctx):
ctx.register_command("whoami", handler=_handle_whoami, description="Show the calling session")
As palavras-chave disponíveis são session_id, session_key, platform e source (o objeto de identidade derivado do adaptador, com user_id, chat_id, thread_id e companhia) — ou simplesmente declare **kwargs e pegue todas. Plugins existentes não precisam de mudança nenhuma: um handler de um argumento continua sendo chamado como handler(raw_args), e handlers async continuam sendo aguardados.
Uma observação de segurança: source é informação de identidade, não uma autorização. Um comando sensível de plugin deve decidir a partir de ids estáveis (user_id, chat_id) e negar por padrão quando source for None.
Três limites para levar em conta no design
- Os dois são observadores. Valores de retorno são ignorados; um callback que levanta exceção é registrado no log e ignorado. A exclusão ou a compactação já foi efetivada — o plugin não pode vetar nem esperar rollback.
- Callbacks têm tempo limitado por
plugins.hook_callback_timeout(30 segundos por padrão). Se você faz chamadas de rede dentro de um hook, defina seu próprio timeout; o que trava é o seu callback, não a conversa. - A entrega é local ao processo, sem garantia de exatamente uma vez. O evento de exclusão dispara apenas no processo que efetivou a exclusão. Efeitos colaterais externos disparados a partir do callback devem tolerar uma chamada perdida ou duplicada.
Quando você pode usar
Os dois hooks foram mesclados na branch main em 18 de outubro, depois da última release v0.22.2, de 15 de outubro — ou seja, eles vivem só no main e não estão em nenhuma versão publicada ainda. Se você instalou o Hermes via PyPI ou por um canal de atualização, não dá para usá-los hoje; rodar a branch main a partir do código-fonte é o caminho para testar antes.
Para a lista completa de hooks e as condições exatas de disparo, a página oficial de Event Hooks é a referência canônica; a referência do comando plugins do site cobre a superfície de CLI. Se você está construindo nessa área, dois posts anteriores combinam bem com este: o broker de tokens para plugins mostra como um plugin autentica um usuário numa API de terceiros sem nunca guardar o token, e a reconstrução do prompt de compactação explica como os prompts são remontados durante a compactação. Escrevendo um plugin de memória? A consolidação de memória no estilo dreaming segue o outro caminho — arrumar as coisas enquanto o agente está ocioso, em vez de reagir à compactação.
A versão curta
post_compaction responde “o que eu faço quando o contexto que injetei é resumido e desaparece”. on_session_delete responde “quem limpa a minha bagunça quando a sessão vai embora”. Nenhum dos dois muda o que o Hermes faz; eles apenas expõem dois momentos que antes eram internos. Para quem escreve plugin, isso é uma classe inteira de polling, adivinhação e fallbacks defensivos que você pode apagar hoje.