O seu gateway fica mudo ao fim de algumas centenas de turnos — havia dois wrappers a empilhar-se no stdout


O seu bot responde o dia inteiro num chat movimentado. Depois, algures a seguir à mensagem 500, para. O processo está vivo, a porta está à escuta, hermes gateway status diz que está tudo bem — mas todas as mensagens novas falham. Abre o log e encontra uma linha que não tem nada a ver com conversa: RecursionError: maximum recursion depth exceeded, com o traceback a terminar em setup_logging. Reinicia o gateway e volta a funcionar. Amanhã à mesma hora, a mesma falha.

Isto não é uma fuga de memória, não é um modelo instável e não é nada que tenha configurado mal. É o stream de output do Python: duas camadas de proxy a empilhar-se uma em cima da outra, até que ao fim de algumas centenas de ciclos a cadeia fica profunda o suficiente para rebentar com o limite de recursão do interpretador.

O sintoma: não é lentidão — é falha

Vale a pena fixar isto antes de ir à procura, porque os sintomas apontam para o subsistema errado:

O que vê O que está realmente a acontecer
Processo do gateway vivo, status diz running O processo está bem; o que rebenta é cada build de agent
RecursionError em setup_logging, vezes sem conta Uma leitura de atributo percorre uma cadeia de proxies muito longa
Reiniciar resolve de imediato A cadeia foi reposta — e começa a crescer outra vez
Mais uptime, pior Escala com turnos e builds de agent, não com o tempo nem com a carga

A palavra-chave é longa duração. Se reiniciar o gateway todos os dias, ou só usar o Hermes a partir de um terminal de vez em quando, provavelmente nunca vai ver isto. Se o correr como serviço com Telegram, Discord, Slack, cron jobs e revisão em background ligados, é um rastilho lento.

O que está nesse stream

Dois desenhos, cada um razoável por si.

O primeiro é _install_safe_stdio(). Cada vez que um agent é construído, envolve o sys.stdout e o sys.stderr num _SafeWriter, para que um pipe partido ou um erro de encoding ao imprimir progresso não derrube o agent inteiro. Como corre em cada build, tem de reconhecer “já está envolvido” — e a verificação antiga era estreita: se o stream já for um _SafeWriter, salta.

O segundo é o proxy de encaminhamento por thread _ThreadRoutingStream, instalado pela revisão em background quando precisa de silenciar o output durante um bocado. Quando thread_scoped_silence() termina, volta a instalar um router.

As duas verificações não concordam:

  • O _install_safe_stdio() só conhecia o _SafeWriter. Ao ver um _ThreadRoutingStream por cima, decidiu “não é meu” e acrescentou outro _SafeWriter.
  • O thread_scoped_silence() deixou então de ver um router, e instalou um novo por cima desse wrapper.

Ou seja, cada ciclo de “construir um agent, silenciar um scope” acrescentava duas ligações a cada stream:

_ThreadRoutingStream( _SafeWriter( _ThreadRoutingStream( _SafeWriter( ... ) ) ) )

Cada ligação encaminha atributos desconhecidos para a seguinte através de __getattr__. Um salto não é nada. Mas com centenas de ligações, uma única leitura de atributo tem de atravessar a cadeia toda — e ao fim de cerca de 500 ciclos, a leitura de line_buffering dentro de setup_logging atinge o limite de recursão e lança RecursionError. A partir daí, todos os turnos falham até o gateway reiniciar e a cadeia cair.

Um detalhe que faz isto parecer mais pequeno do que é: o gatilho não é “muitas mensagens”. São builds de agent. Um turno que lança subagents, corre revisão em background e passa por várias iterações de tool constrói muito mais agents do que se adivinharia pela contagem de mensagens.

A correção: uma verificação isinstance

A alteração mesclada é minúscula e vive em agent/process_bootstrap.py:

def _install_safe_stdio() -> None:
    """Wrap stdout/stderr so best-effort console output cannot crash the agent."""
    for stream_name in ("stdout", "stderr"):
        stream = getattr(sys, stream_name, None)
        # The routing proxy already absorbs these errors; wrapping it stacks a new proxy per build.
        if stream is not None and not isinstance(stream, (_SafeWriter, _ThreadRoutingStream)):
            setattr(sys, stream_name, _SafeWriter(stream))

A guarda passa a reconhecer a família inteira em vez de um único membro. O router já absorve os dois erros para os quais o _SafeWriter existe (OSError e ValueError, tratados no seu próprio caminho de encaminhamento e no isatty()), por isso não precisa de uma segunda casca protetora à volta.

Não há passagem de limpeza nem protocolo de coordenação entre os dois instaladores: verifica o tipo uma vez e a cadeia fica plana para sempre. Aplica-se a todas as superfícies do gateway — Telegram, Discord, Slack, Desktop, o servidor de API — porque todas passam pelo mesmo bootstrap de agent.

O que fazer agora

1. Confirme que é este bug. Não adivinhe, conte:

hermes logs errors --since 7d | grep -c RecursionError
hermes logs errors --since 2d --level ERROR

Uma contagem que cresce ao longo dos dias, volta a zero no restart e sobe outra vez é a assinatura. Depois veja quantos gateways está a correr:

hermes gateway status
hermes gateway list

2. Ponha o serviço de volta. Reiniciar interrompe turnos em curso, mas é a única recuperação imediata:

hermes gateway restart
# vários profiles lado a lado
hermes gateway restart --all

3. Saiba quando chega a correção. A alteração foi mesclada na main a 19 de outubro de 2026 — depois de a v0.22.2 ter saído a 15 de outubro — por isso a release atual não a inclui. O seu próximo hermes update para um build que inclua o commit traz a correção; reinicie o gateway a seguir. Se correr a partir de um checkout de código que segue a main, faça pull e reinicie.

4. Trate um gateway como um serviço de longa duração. Este bug é cumulativo, não vem da carga: aparece em processos que correm durante semanas e constroem dezenas de milhares de agents. Dê ao serviço uma política de supervisão que o reinicie em caso de falha (systemd Restart=on-failure, launchd KeepAlive), para que uma classe de bug destas não o deixe em silêncio durante a noite. O Hermes tem a sua própria maquinaria de watchdog e heartbeat que vale a pena juntar a isso — veja o guia do watchdog do loop do gateway e a deteção de desconexões silenciosas por heartbeat.

Porque vale a pena guardar este caso

Duas lições, nenhuma específica do Hermes.

Wrappers num hot path têm de ser idempotentes para a família toda, não para um membro. Qualquer lógica do tipo “envolve se não estiver envolvido” com uma verificação de tipo estreita transforma-se, em silêncio, em “envolve em cada chamada”. Os testes unitários não apanham isto, porque um teste constrói um agent; só um processo de longa duração o expõe, e processos de longa duração são a coisa mais difícil de reproduzir.

As mensagens de erro mentem sobre a sua camada. RecursionError lê-se como recursão descontrolada na lógica de negócio, mas vinha de uma cadeia de encaminhamento de atributos na camada de I/O. Quando o topo do traceback não tem nada a ver com o que o código está a fazer, suspeite de estado de infraestrutura acumulado antes de começar a ler a lógica de negócio.

Se estiver a depurar o comportamento do gateway, confirme os seus comandos na referência do hermes gateway primeiro, e use o cron doctor do lado dos agendamentos — os cron jobs são normalmente a primeira coisa a queixar-se quando um gateway fica doente.

Resumo

  • Sintoma: um gateway de longa duração falha em todos os turnos ao fim de algumas centenas de builds, RecursionError vindo de setup_logging, o restart limpa tudo.
  • Causa: _install_safe_stdio() e thread_scoped_silence() não reconheciam o proxy um do outro, por isso cada ciclo acrescentava duas ligações a cada stream; cerca de 500 ciclos depois, uma leitura de atributo passa o limite de recursão.
  • Correção: uma verificação isinstance que cobre a família toda de proxies. Mesclada na main, depois da v0.22.2.
  • Hoje: confirme com hermes logs errors, recupere com hermes gateway restart, espere pela próxima atualização — e supervise o serviço.