Ajustando um Agente no Estilo Hermes que Cresce com o Seu Projeto
Um assistente de IA específico por projeto construído sobre um canal de mensagens — Telegram, Discord, Slack, ou e-mail através de um gateway enxuto — se comporta de forma diferente em um grupo com múltiplos usuários do que em um DM individual. Integrar uma equipe ao bot expõe uma classe de bugs que o caminho de teste via DM nunca alcança: crashes por resposta vazia em triggers baseados em palavras-chave, notificações de progresso descalibradas, race conditions entre triggers paralelos e deriva de memória no próprio log de conversa do agente.
Este guia documenta oito padrões de ajuste que aplicamos a um assistente mínimo no estilo hermes — construído sobre o Claude Code CLI como um subprocess em vez do framework completo da NousResearch. Cada padrão é um problema concreto com o código para resolvê-lo. Os padrões se aplicam a qualquer uma das duas bases de código.
O Que Isso Corrige
- Respostas genéricas de
Unexpected errorquando o LLM retorna stdout vazio em um trigger por palavra-chave - Texto de reassurance disparando em toda resposta curta porque o threshold está abaixo da latência típica de resposta
- Subprocesses que travam no meio do stream e nunca retornam, sem timeout para encerrá-los
- Race conditions quando dois membros do grupo disparam o agente simultaneamente contra uma sessão compartilhada
- Deadlocks silenciosos de pipe de stderr assim que o agente roda tempo suficiente para preencher o buffer de 64 KB
- Mensagens de fallback de bug no log de conversa que o agente lê de volta como comportamento legítimo
Pré-requisitos
Este guia pressupõe a arquitetura de Construindo um Assistente de IA Específico por Projeto via Telegram: um container Docker por projeto, usuário não-root, Claude Code CLI persistente autenticado via OAuth, wrapper de bot aiogram, volumes bind-mounted de workspace e state de sessão. Vários padrões dependem de versões específicas:
- Claude Code CLI 2.1.139 ou superior (para
--output-format stream-json --verbose --include-partial-messages) - aiogram 3.28.2 ou superior (para
ChatActionSender,message.react(),ReactionTypeEmoji) - Imagem base Python 3.13
- Um grupo no Telegram onde o bot é membro com
can_react_to_messages: true
Para um canal de e-mail no mesmo projeto, veja como configurar uma caixa de correio de projeto com DKIM, SPF e DMARC.
O Que é um Agente no Estilo Hermes
O padrão hermes-style recebe o nome do framework aberto da NousResearch. Três propriedades o distinguem de um chatbot sem estado:
- Memória persistente. Um workspace em disco que o agente lê e escreve entre os turnos, de modo que o contexto sobrevive a reinicializações do container.
- Presença multicanal. A mesma instância do agente conversa no Telegram, Discord, Slack ou e-mail através de um gateway enxuto.
- Um ciclo de aprendizado fechado. Correções do operador tornam-se edições no workspace que o agente lê no próximo turno.
A NousResearch fornece uma implementação de referência completa com TUI, gateway multicanal, sistema de habilidades e hooks de treinamento RL. Uma variante mínima sobre o subprocess do Claude Code CLI mantém as partes móveis pequenas o suficiente para ser usada como template por mandato de consultoria. Os padrões abaixo se aplicam igualmente a qualquer uma das abordagens.
Padrão 1: Tratamento Tipado de Resposta Vazia
Um trigger baseado em palavra-chave (que corresponde a \bhermes\b em mensagens de grupo) pode disparar em uma frase que contém o nome do bot mas não está endereçada a ele. O LLM retorna corretamente uma saída vazia. Três camadas downstream falham ao lidar com o caso vazio:
- O engine retorna
""com returncode 0. - A função de split retorna
[""]porquelen("") <= max_charscorresponde. - O loop de envio chama
bot.send_message(chat_id, ""); o Telegram retornaBad Request: message text is empty; umexcept Exceptiongenérico no topo do handler engole o traceback e envia o erro ao usuário.
Filtrar strings vazias em uma camada evita o crash, mas produz um skip silencioso — o trigger disparou, o bot consumiu processamento, o usuário não vê nada. A correção em dois passos usa uma exceção tipada para saída vazia e uma reação do Telegram (👀) na mensagem que originou o trigger como confirmação:
class HermesEmptyResponse(HermesError):
"""Subprocess retornou com sucesso mas com resultado vazio."""
class HermesHangError(HermesError):
"""Watchdog encerrou o subprocess após N segundos sem evento de stream."""
O engine lança HermesEmptyResponse quando result.strip() == "". O handler captura e chama message.react([ReactionTypeEmoji(emoji="👀")]). O log de conversa recebe um bloco marcador — uma entrada separada que registra a confirmação silenciosa sem poluir o chat com texto — para que as leituras de memória futuras do próprio agente indiquem que um trigger disparou e foi intencionalmente não respondido.
Padrão 2: Pre-Flight de Permissão de Reação com Cache Lazy
O setMessageReaction do Telegram não está universalmente disponível. Alguns grupos restringem o conjunto de reações permitidas; alguns emojis personalizados exigem allowlisting pelo administrador. O tipo ChatFullInfo documenta a regra: se available_reactions for omitido, todos os emojis padrão são permitidos; se for um array, apenas esses emojis funcionam. O bot precisa ser membro do grupo — status de administrador não é necessário para reações em grupos.
Verificação por trigger desperdiça chamadas de API. Um getChat por chat com um cache de uma hora é suficiente:
_reaction_cache: dict[int, tuple[bool, float]] = {}
_REACTION_CACHE_TTL_SEC = 3600
MINI_ACK_EMOJI = "👀"
async def _reactions_allowed(bot: Bot, chat_id: int) -> bool:
now = time.monotonic()
cached = _reaction_cache.get(chat_id)
if cached and cached[1] > now:
return cached[0]
try:
chat = await bot.get_chat(chat_id)
allowed = (
chat.available_reactions is None
or any(
isinstance(r, ReactionTypeEmoji) and r.emoji == MINI_ACK_EMOJI
for r in (chat.available_reactions or [])
)
)
except Exception:
allowed = False
_reaction_cache[chat_id] = (allowed, now + _REACTION_CACHE_TTL_SEC)
return allowed
Envolva a chamada real de reação em try/except (TelegramBadRequest, TelegramForbiddenError) independentemente — o cache fica desatualizado em relação a mudanças de permissão.
Padrão 3: Modo Stream e o Watchdog de Tempo Ocioso
Um timeout fixo em todo o subprocess (asyncio.wait_for(proc.communicate(), timeout=300)) limita a duração total independentemente do progresso. Removê-lo sem substituto está documentado como inseguro: o problema de travamento ocioso de stream do Claude Code descreve chamadas de API que travam no meio do stream e nunca retornam, vazando um subprocess.
Mudar para --output-format stream-json --verbose --include-partial-messages emite eventos a cada marco — text_delta por token, início e fim de uso de ferramenta, retentativas de API, avisos de rate-limit, o evento final result. Um travamento real produz silêncio no stream; uma tarefa longa produz uma sequência de pequenos eventos. O watchdog encerra no tempo ocioso, não na duração total:
WATCHDOG_NO_EVENT_SEC = 60
async def watchdog() -> None:
while True:
await asyncio.sleep(5)
if proc.returncode is not None:
return
idle_sec = time.monotonic() - state["last_event_ts"]
if idle_sec > WATCHDOG_NO_EVENT_SEC:
state["killed_by_watchdog"] = True
try:
proc.kill()
except ProcessLookupError:
pass
return
O texto de resposta final vem do campo result do evento result — determinístico, fonte única e não afetado pela análise de stream parcial. O mesmo evento carrega is_error, api_error_status, duration_ms e total_cost_usd, todos os quais vão para a linha de log estruturado.
Padrão 4: Calibrando o Cronograma de Reassurance
A questão do threshold — quando o bot envia uma atualização de texto durante uma chamada demorada — é empírica. A resposta certa depende da distribuição de latência dos triggers reais. Três thresholds, com texto alinhado ao que o usuário realmente precisa saber:
_REASSURE_SCHEDULE = (
(15, "Já estou nisso."),
(90, "Está demorando mais que o normal, ainda em andamento."),
(300, "Tarefa genuinamente grande — quase pronto."),
)
Os thresholds derivam de duas restrições. O limite inferior é definido pela latência típica de resposta curta: se a maioria das respostas chegar dentro de X segundos, a primeira reassurance deve disparar depois de X, caso contrário chegará aproximadamente no mesmo momento que a resposta. A pesquisa de tempo de resposta de Nielsen identifica 10 segundos como o limite canônico para manter a atenção do usuário sem um indicador de progresso; o indicador de digitação que o ChatActionSender do aiogram exibe abaixo desse threshold já satisfaz o requisito até cerca de 15 segundos.
O threshold superior (90 segundos) é o intervalo a partir do qual o enquadramento muda de trabalhando para trabalhando, mas mais do que o normal — um sinal separado de que a chamada está na cauda longa da distribuição. O texto evita implicar que o usuário pediu algo pesado. O bot é quem está fazendo o trabalho; a mensagem reconhece esse trabalho, não a solicitação.
Padrão 5: Lock de Concorrência por Chat
Dois membros do grupo podem disparar o agente dentro do mesmo segundo — um com uma @menção, outro com a palavra-chave. Ambas as invocações do handler geram subprocesses claude --continue contra o mesmo arquivo de sessão persistente. O lock-file de sessão não é estrito; escritas concorrentes produzem arquivos session-jsonl truncados e turnos perdidos.
Serialize por chat na camada do handler com um lock criado de forma lazy:
_chat_locks: dict[int, asyncio.Lock] = {}
def _get_chat_lock(chat_id: int) -> asyncio.Lock:
lock = _chat_locks.get(chat_id)
if lock is None:
lock = asyncio.Lock()
_chat_locks[chat_id] = lock
return lock
async with _get_chat_lock(message.chat.id):
response = await _run_hermes_with_ux(bot, message, prompt, ctx)
...
A criação lazy é importante: um asyncio.Lock instanciado no momento da importação do módulo se vincula ao event loop que estiver ativo na importação, que pode não ser o loop no qual o handler executa após uma reinicialização. Adiar a instanciação até a primeira chamada dentro de um loop ativo evita o mismatch de vínculo. Para grupos pequenos, o dicionário de locks permanece pequeno; para frotas maiores, adicione evicção LRU.
Padrão 6: Hierarquia de Exceções e Ordem dos Excepts
As classes de exceção do engine formam uma árvore:
HermesError(RuntimeError)— qualquer problema com o subprocessHermesEmptyResponse(HermesError)— execução bem-sucedida com resultado vazioHermesHangError(HermesError)— watchdog encerrou o processo
O except do Python corresponde à primeira cláusula compatível. Se except HermesError preceder os handlers de subclasse, ele captura HermesEmptyResponse e a roteia para o caminho de erro, contornando o mini-ack. A ordenação subclasse-primeiro é obrigatória:
try:
response = await _run_hermes_with_ux(bot, message, prompt, ctx)
...
except HermesEmptyResponse:
# caminho do mini-ack
...
except HermesHangError as exc:
# caminho de retry-uma-vez-depois-desiste
...
except HermesError as exc:
# exit-not-zero, api-error, etc.
...
except Exception:
# último recurso
...
Adicione isso a um checklist de code review. Reordenar os blocos por inspeção visual inverte a intenção.
Padrão 7: Drenando o stderr em Paralelo
Fazer streaming via stdout exige ler linhas conforme chegam: async for line in proc.stdout. Se o stderr também estiver em pipe, o subprocess pode preencher seu buffer de stderr enquanto o stdout ainda está sendo lido. Os buffers de pipe padrão no Linux têm cerca de 64 KB. Assim que o stderr enche, o subprocess bloqueia aguardando que seja drenado, e o loop async-for-line nunca avança. O watchdog eventualmente o encerra após o período ocioso, mas o resultado é perdido.
Drene o stderr em paralelo desde o início do subprocess e aguarde a task de drenagem após proc.wait():
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(WORKSPACE),
)
stderr_task = asyncio.create_task(proc.stderr.read())
# ... loop de stream no stdout ...
rc = await proc.wait()
try:
stderr_b = await stderr_task
except Exception:
stderr_b = b""
stderr = stderr_b.decode("utf-8", errors="replace").strip()
O Claude Code CLI emite pouco stderr no modo stream-json, portanto o modo de falha é raro na prática. A correção é uma linha extra.
Padrão 8: Disciplina de Edição de Memória
Um agente no estilo hermes lê seus próprios logs de conversa como memória. Mensagens de fallback de bug escritas nesse log tornam-se indistinguíveis de comportamento passado intencional na próxima leitura. O primeiro impulso é inserir marcadores de correção ([CORREÇÃO: a entrada anterior foi um bug]) para que a próxima leitura de memória veja a correção.
Verifique se o fallback de bug foi de fato registrado antes de editar. No caso acima, o bloco genérico except Exception chamou message.answer(...) para enviar o erro ao usuário, mas não chamou conversation_log.log_outgoing(...). A mensagem de erro chegou ao Telegram, mas nunca chegou ao arquivo de memória do agente. Nenhuma edição retroativa foi necessária.
Trate o workspace do agente como sendo do agente. Antes de qualquer plano que envolva editar arquivos dentro dele, tire um snapshot do estado atual — o agente pode ter reescrito seu próprio CLAUDE.md ou notas desde a última leitura. O guia de context engineering da Anthropic descreve a memória persistente como um artefato entre sessões, não um bloco de rascunho em que o operador rabisca. Habilidades específicas de domínio se mantêm mais duráveis quando residem junto a notas curadas pelo próprio agente do que em arquivos editados pelo operador que o agente aprende a desconfiar.
Notas Operacionais
Persistência de bind-mount. Volumes bind-mounted para o workspace e as credenciais OAuth do Claude sobrevivem a docker compose up -d --force-recreate enquanto os caminhos de montagem permanecerem inalterados. Verifique antes de qualquer edição no compose-file.
Verificação pré-deploy. Faça grep nos últimos cinco minutos de logs por claude_subprocess_start sem um claude_result_event correspondente. Um subprocess pendente significa que uma reinicialização vai encerrar uma execução em andamento. Aguarde até que os logs estejam limpos. Para cenários de falha mais amplos, veja nosso writeup de disaster recovery.
Reusabilidade dos padrões entre mandatos. O stack completo — engine, handlers, log de conversa, file intake — é clonado para um novo mandato alterando duas variáveis de ambiente (um nome de projeto e um id de instância). O token do bot, as credenciais OAuth, o workspace e a allow-list são parametrizados por projeto. Para a perspectiva operacional de rodar muitos assistentes por projeto em paralelo, veja operações solo em escala.
Seleção de emoji de reação. O emoji 👀 faz parte do conjunto padrão do Telegram e funciona em grupos onde available_reactions não está definido. Se um grupo restringir a um subconjunto personalizado, o cache reflete isso e o mini-ack é silenciosamente ignorado. Torne o emoji uma constante de configuração por deployment em vez de um literal hardcoded.
Hermes-Agent versus um build mínimo personalizado. O framework da NousResearch inclui TUI, sistema de slash-commands, gateway multicanal, hub de habilidades e integração de treinamento RL. Um wrapper mínimo do Claude Code CLI produz a mesma forma conversacional com aproximadamente um décimo das partes móveis. Ambos convergem para o mesmo conjunto de problemas de UX em chat de grupo; os padrões deste post se aplicam a qualquer um.
Quando Aplicar Cada Padrão
Os padrões não são igualmente urgentes. Aplique-os na ordem em que forem encontrados:
- Padrão 1 (tratamento de resposta vazia) é necessário assim que o bot é adicionado a um grupo com detecção de trigger por palavra-chave.
- Padrão 4 (cronograma de reassurance) é necessário após a primeira resposta curta chegar ao mesmo tempo que a mensagem de reassurance.
- Padrões 3 e 7 (modo stream, drenagem de stderr) são necessários assim que tarefas demoradas começam a travar.
- Padrão 5 (lock de concorrência) é necessário quando a primeira truncagem de arquivo de sessão aparecer nos logs.
- Padrões 2, 6 e 8 são hardening de fundo — aplique durante o code review antes que quebrem em produção.
Construa primeiro o assistente específico por projeto: o guia de arquitetura base cobre o container, OAuth, workspace e layout de handlers. Integre uma equipe pequena com o guia de escalabilidade para allow-lists, configuração de grupo e detecção de trigger. Adicione o canal de e-mail para o projeto com o guia de DKIM/DMARC quando notificações fora de banda começarem a chegar. Retorne a este post quando os padrões acima forem necessários.
a tva executa vários assistentes por projeto em paralelo para diferentes mandatos de consultoria. Para obter ajuda na construção ou ajuste do seu, entre em contato.
Insights Relacionados
- Construindo um Assistente de IA Específico por Projeto via Telegram — a arquitetura base que este guia ajusta
- Escalando um Assistente de IA no Telegram de Solo para Equipe — allow-lists, configuração de grupo, detecção de trigger
- Configurando uma Caixa de Correio para Seu Agente de IA Específico por Projeto — o canal de e-mail para o mesmo padrão por projeto
- Construindo Habilidades de Agente de IA para Fluxos de Trabalho de Negócios Específicos de Domínio — tornando o assistente útil para um domínio
- Operações Solo em Escala: Gerenciando Dezenas de Projetos com uma Equipe Pequena — operando muitos assistentes em paralelo