Pular para o conteúdo principal

Documentação oficial desta página · v0.20.6

Cron por Dentro

O subsistema de cron oferece execução de tarefas agendadas — de atrasos simples de uma vez só a jobs recorrentes com expressão de cron, injeção de skill e entrega em várias plataformas.

Arquivos principais​

ArquivoPapel
cron/jobs.pyModelo de job, armazenamento, leitura/escrita atômica no jobs.json
cron/scheduler.pyLaço do agendador — detecção de job vencido, execução, controle de repetições
tools/cronjob_tools.pyRegistro e handler da ferramenta cronjob exposta ao modelo
gateway/run.pyIntegração com o gateway — o tique do cron dentro do laço longo
hermes_cli/cron.pySubcomandos hermes cron da CLI

Modelo de agendamento​

Há suporte a quatro formatos de agendamento:

FormatoExemploComportamento
Atraso relativo30m, 2h, 1dUma vez só, dispara depois da duração indicada
Intervaloevery 2h, every 30mRecorrente, dispara em intervalos regulares
Expressão de cron0 9 * * *Sintaxe cron padrão de 5 campos (minuto, hora, dia, mês, dia da semana)
Timestamp ISO2025-01-15T09:00:00Uma vez só, dispara na hora exata

A superfície exposta ao modelo é uma única ferramenta cronjob com operações em estilo de ação: create, list, update, pause, resume, run, remove.

Armazenamento dos jobs​

Os jobs ficam em ~/.hermes/cron/jobs.json, com escrita atômica (grava num arquivo temporário e depois renomeia). Cada registro de job contém:

{
"id": "a1b2c3d4e5f6",
"name": "Briefing diário",
"prompt": "Resuma as notícias e as rodadas de investimento em IA de hoje",
"schedule": {
"kind": "cron",
"expr": "0 9 * * *",
"display": "0 9 * * *"
},
"skills": ["ai-funding-daily-report"],
"deliver": "telegram:-1001234567890",
"repeat": {
"times": null,
"completed": 42
},
"state": "scheduled",
"enabled": true,
"next_run_at": "2025-01-16T09:00:00Z",
"last_run_at": "2025-01-15T09:00:00Z",
"last_status": "ok",
"created_at": "2025-01-01T00:00:00Z",
"model": null,
"provider": null,
"script": null
}

Estados do ciclo de vida do job​

EstadoSignificado
scheduledAtivo, vai disparar no próximo horário agendado
pausedSuspenso — não dispara até ser retomado
completedA contagem de repetições acabou, ou era de uma vez só e já disparou
runningExecutando agora (estado passageiro)

Compatibilidade com o passado​

Jobs antigos podem ter um campo único skill em vez do array skills. O agendador normaliza isso no carregamento — um skill único vira skills: [skill].

Runtime do agendador​

Ciclo de tique​

O agendador roda num tique periódico (padrão: a cada 60 segundos):

tick()
1. Pega a trava do agendador (evita tiques sobrepostos)
2. Carrega todos os jobs do jobs.json
3. Filtra os jobs vencidos (next_run <= agora E state == "scheduled")
4. Para cada job vencido:
a. Muda o estado para "running"
b. Cria uma sessão nova de AIAgent (sem histórico de conversa)
c. Carrega as skills anexadas na ordem (injetadas como mensagens de usuário)
d. Roda o prompt do job pelo agente
e. Entrega a resposta no alvo configurado
f. Atualiza run_count e calcula next_run
g. Se a contagem de repetições acabou → state = "completed"
h. Caso contrário → state = "scheduled"
5. Grava os jobs atualizados de volta no jobs.json
6. Solta a trava do agendador

Integração com o gateway​

No modo gateway, o gatilho do cron (a parte que decide quando um job vencido dispara — o "Eixo B") é escolhido por um provedor plugável de CronScheduler. O gateway chama resolve_cron_scheduler() (cron/scheduler_provider.py) e roda o start() do provedor resolvido numa thread de segundo plano dedicada, ao lado de uma thread separada de manutenção do gateway.

O provedor ativo é escolhido pela chave de configuração cron.provider:

  • vazio (padrão) → o InProcessCronScheduler embutido, que roda o laço histórico dentro do processo chamando scheduler.tick() a cada 60 segundos. Isso é idêntico byte a byte ao comportamento anterior aos provedores.
  • um provedor nomeado (por exemplo, chronos, um provedor de cron gerenciado para implantações que escalam a zero) → descoberto em plugins/cron_providers/<nome>/ ou $HERMES_HOME/plugins/<nome>/.

Se um provedor nomeado não existe, falha ao carregar ou reporta is_available() == False, o resolvedor recai no embutido com um aviso — o cron nunca fica sem gatilho. O provedor embutido mora no núcleo (cron/scheduler_provider.py), e não em plugins/, então o plano B não pode ser removido por acidente.

O que "disparar" significa (execução do job + entrega) não muda e é compartilhado por todos os provedores — isso continua em scheduler.run_job() / scheduler._deliver_result(). Um provedor só controla o gatilho, nunca a execução.

No modo CLI, os jobs de cron só disparam quando comandos hermes cron são rodados ou durante sessões ativas de CLI.

Cron gerenciado (Chronos) para escalar a zero​

Gateways hospedados podem rodar o provedor Chronos (cron.provider: chronos) em vez do tiquetaque embutido. O Chronos permite que um gateway ocioso escale a zero e ainda assim dispare os jobs de cron: em vez de um laço de 60 segundos dentro do processo (que manteria o processo acordado), ele pede à infraestrutura da Nous para armar exatamente um disparo gerenciado único por job, no horário real da próxima execução daquele job. Na hora do disparo, a Nous chama o gateway de volta por um webhook autenticado (POST /api/cron/fire); o gateway roda o job pelo mesmo caminho run_one_job do embutido e depois arma o próximo disparo único. Entre um disparo e outro o processo pode ficar completamente parado — ele acorda só num disparo de verdade, nunca por um timer periódico.

O fluxo (o agendador gerenciado é fornecido pela Nous; o agente não guarda credencial de agendador):

criar/atualizar um job de cron
→ o Chronos pede à Nous para armar um disparo único no next_run_at do job
(autenticado com o token da Nous que o agente já tem)
→ na hora do disparo, a Nous chama o gateway: POST {callback_url}/api/cron/fire
(autenticado com um JWT de vida curta e escopo específico, emitido pela Nous)
→ o gateway verifica o token, reivindica o job (compare-and-set no armazenamento, para que
implantações com várias réplicas disparem no máximo uma vez), roda o job e arma o próximo
disparo único

Configuração (nada secreto; em agentes hospedados a Nous define isto no provisionamento):

chavesignificado
cron.providerchronos para ativar (vazio = tiquetaque embutido)
cron.chronos.portal_urlURL base da Nous (armar + emitir o token de disparo)
cron.chronos.callback_urla URL base pública do próprio gateway, para os disparos que chegam
cron.chronos.expected_audiencea audiência do token de disparo deste agente
cron.chronos.nas_jwks_urlconjunto de chaves para verificar o token de disparo que chega

Se o Chronos está mal configurado ou o agente não está logado na Nous, o resolve_cron_scheduler() recai no tiquetaque embutido (com aviso no log) — o cron nunca perde o gatilho. Jobs recorrentes se rearmam depois de cada disparo; jobs com repeat-N param direitinho quando a contagem acaba (sem disparo único órfão). O contrato completo de fio entre agente e Nous está em docs/chronos-managed-cron-contract.md.

Isolamento em sessão nova​

Cada job de cron roda numa sessão de agente completamente nova:

  • Sem histórico de conversa das execuções anteriores
  • Sem lembrança das execuções anteriores do cron (a memória persistente — MEMORY.md / USER.md — carrega sim, como em qualquer outra execução do agente, então preferências e fatos duráveis atravessam; o contexto de conversa de cada execução, não)
  • O prompt precisa se bastar — jobs de cron não podem fazer perguntas de esclarecimento
  • O conjunto de ferramentas cronjob fica desligado (proteção contra recursão)

Jobs apoiados em skill​

Um job de cron pode anexar uma ou mais skills pelo campo skills. Na hora da execução:

  1. As skills são carregadas na ordem indicada
  2. O conteúdo do SKILL.md de cada uma é injetado como contexto
  3. O prompt do job é acrescentado como a instrução da tarefa
  4. O agente processa o contexto das skills somado ao prompt

Isso viabiliza fluxos reutilizáveis e testados, sem colar instruções inteiras nos prompts de cron. Por exemplo:

Criar um relatório diário de investimentos → anexar a skill "ai-funding-daily-report"

Jobs apoiados em script​

Os jobs também podem anexar um script Python pelo campo script. O script roda antes de cada turno do agente, e a saída padrão dele é injetada no prompt como contexto. Isso viabiliza padrões de coleta de dados e de detecção de mudança:

# ~/.hermes/scripts/check_competitors.py
import requests, json
# Busca as notas de versão dos concorrentes, compara com a execução anterior
# Imprime o resumo na saída padrão — o agente analisa e reporta

O tempo limite do script é de 3600 segundos (1 hora) por padrão. O _get_script_timeout() resolve o limite por uma cadeia de três camadas:

  1. Sobrescrita no nível do módulo — _SCRIPT_TIMEOUT (para testes e monkeypatching). Só é usada quando difere do padrão.
  2. Variável de ambiente — HERMES_CRON_SCRIPT_TIMEOUT
  3. Configuração — cron.script_timeout_seconds no config.yaml (lida via load_config())
  4. Padrão — 3600 segundos (1 hora)

Esse tempo limite vale só para o script de pré-execução, não para o agente. Jobs baseados em skill ou movidos a LLM correm com um orçamento separado, baseado em inatividade (HERMES_CRON_TIMEOUT, padrão de 600s de tempo parado, 0 = ilimitado) — eles podem rodar por horas enquanto continuarem chamando ferramentas ou emitindo tokens, e só são mortos depois do período de inatividade configurado sem nenhuma atividade. Os scripts são despachados para um pool de threads persistente (não ficam presos sob a trava do tique), então um script demorado não impede outros jobs vencidos de disparar.

Recuperação de provedor​

O run_job() passa os provedores de reserva configurados pela pessoa e o pool de credenciais para a instância do AIAgent:

  • Provedores de reserva — lê fallback_providers (lista) ou fallback_model (dicionário antigo) do config.yaml, seguindo o mesmo padrão do _load_fallback_model() do gateway. É passado como fallback_model= para o AIAgent.__init__, que normaliza os dois formatos numa cadeia de reserva.
  • Pool de credenciais — carregado por load_pool(provider) de agent.credential_pool, usando o nome do provedor de runtime já resolvido. Só é passado quando o pool tem credenciais (pool.has_credentials()). Permite rodízio de chaves do mesmo provedor em erros 429 e de limite de taxa.

Isso espelha o comportamento do gateway — sem isso, os agentes de cron falhariam em limites de taxa sem nem tentar se recuperar.

Modelo de entrega​

Os resultados de um job de cron podem ser entregues em qualquer plataforma suportada.

Um nome de plataforma sozinho (slack, telegram, …) entrega no canal padrão configurado daquela plataforma. Para mirar um destino específico, acrescente um alvo depois de dois-pontos: platform:<alvo>. O alvo é resolvido na hora do disparo (não na criação do job), então um job pode nomear um destino numa plataforma ainda não conectada e começar a entregar assim que ela subir.

A maioria das plataformas também aceita uma thread ou um tópico opcional como terceiro segmento: platform:<chat_id>:<thread_id>.

AlvoSintaxeExemplo
Chat de origemoriginEntrega no chat onde o job foi criado
Arquivo locallocalSalva em ~/.hermes/cron/output/
Telegramtelegram, telegram:<chat_id>, telegram:<chat_id>:<thread_id>, telegram:@usernametelegram:-1001234567890:17585
Discorddiscord, discord:#channel, discord:<channel_id>, discord:<channel_id>:<thread_id>discord:#engineering
Slackslack, slack:#channel, slack:<channel_id>, slack:<channel_id>:<thread_ts>slack:#engineering
Matrixmatrix, matrix:<!room_id:server>, matrix:<@user:server>matrix:!abc123:example.org
Feishufeishu, feishu:<chat_id>, feishu:<chat_id>:<thread_id>feishu:oc_abc123def
WhatsAppwhatsapp, whatsapp:<jid>, whatsapp:+<E.164>whatsapp:123456@g.us
Signalsignal, signal:group:<id>, signal:+<E.164>signal:group:aBcD==
SMSsms, sms:+<E.164>sms:+<número E.164>
E-mailemail, email:<endereço>email:alertas@exemplo.com
Weixinweixin, weixin:<wxid>weixin:wxid_abc123
Mattermostmattermost ou mattermost:<channel_id>O nome sozinho entrega no canal padrão do Mattermost
Home Assistanthomeassistant ou homeassistant:<conversation>O nome sozinho entrega na conversa do HA
DingTalkdingtalk ou dingtalk:<chat_id>O nome sozinho entrega no DingTalk
WeComwecom ou wecom:<chat_id>O nome sozinho entrega no WeCom
BlueBubblesbluebubbles ou bluebubbles:<chat_guid>O nome sozinho entrega no iMessage via BlueBubbles
QQ Botqqbot ou qqbot:<chat_id>O nome sozinho entrega no QQ (Tencent) pela API oficial v2
Bot Chatbot-chat ou bot-chat:<profile>Injeta no Bot Chat canônico de um perfil local (o bot responde)

As plataformas do primeiro grupo têm sintaxe de alvo explícita e validada — canais nomeados (#channel), tópicos/threads, IDs de sala ou de usuário, IDs de grupo ou números de telefone. As outras plataformas aceitam a forma genérica platform:<chat_id> (o valor depois dos dois-pontos é usado tal e qual como ID de destino); um nome de plataforma sozinho sempre entrega no canal padrão.

Canais nomeados (slack:#engineering, discord:#engineering ou um nome amigável como slack:engineering) são resolvidos contra o diretório de canais que o gateway monta a partir dos adaptadores conectados, então o gateway precisa ter descoberto o canal para que a resolução por nome funcione; IDs crus (slack:C0123ABCD45) sempre funcionam.

Para tópicos do Telegram, use telegram:<chat_id>:<thread_id> (por exemplo, telegram:-1001234567890:17585). Para threads do Slack, o terceiro segmento é o thread_ts da mensagem pai (por exemplo, slack:C0123ABCD45:1700000000.000100), então isso só vale para responder sob uma mensagem existente.

O Bot Chat (bot-chat, bot-chat:<profile>) é uma pseudoplataforma local da máquina, não um adaptador de gateway: o agendador entrega rodando hermes [-p <profile>] chat --in ~ -c "Bot Chat" --create-if-missing -Q --query-file <tmp> — a mesma faixa que as mensagens de agente para agente do Bot Mode usam — então a saída chega como um turno de entrada de verdade no Bot Chat canônico daquele perfil e o bot roda um turno completo de agente em cima dela (seguro quanto à alternância por construção; esta é a faixa do comando de chat, não um espelho de transcrição). O token sozinho mira o perfil do próprio job; a forma nomeada é validada contra ~/.hermes/profiles/ na criação e de novo no disparo, e nunca resolve entre máquinas. Alvos de bot-chat ficam de fora do token de roteamento all e da checagem prévia de entrega (não envolve credencial de gateway nenhuma). O tempo limite do subprocesso por entrega é cron.bot_chat_delivery_timeout_seconds (padrão 600).

Embrulho da resposta​

Por padrão (cron.wrap_response: true), as entregas do cron vêm embrulhadas com:

  • Um cabeçalho identificando o nome do job de cron e a tarefa
  • Um rodapé lembrando que o agente não consegue ver a mensagem entregue na conversa

O prefixo [SILENT] numa resposta de cron suprime a entrega por completo — útil para jobs que só precisam escrever em arquivos ou provocar efeitos colaterais.

Isolamento de sessão​

As entregas do cron NÃO são espelhadas no histórico de conversa das sessões do gateway. Elas existem só na sessão do próprio job de cron. Isso evita violações de alternância de mensagens na conversa do chat de destino.

Proteção contra recursão​

As sessões rodadas pelo cron têm o conjunto de ferramentas cronjob desligado. Isso evita:

  • Que um job agendado crie novos jobs de cron
  • Um agendamento recursivo que poderia explodir o uso de tokens
  • Alteração acidental do agendamento do job de dentro do próprio job

Travas​

O agendador usa travas de arquivo entre processos (fcntl.flock no Unix, msvcrt.locking no Windows) para impedir que tiques sobrepostos executem o mesmo lote de jobs vencidos duas vezes — até mesmo entre o tiquetaque no processo do gateway e uma chamada avulsa de hermes cron ou de tick() manual. Se a trava não puder ser obtida, o tick() devolve 0 na hora.

Interface de linha de comando​

A CLI hermes cron oferece gerenciamento direto dos jobs:

hermes cron list # Mostra todos os jobs
hermes cron create # Criação interativa de job (apelido: add)
hermes cron edit <job_id> # Edita a configuração do job
hermes cron pause <job_id> # Pausa um job em atividade
hermes cron resume <job_id> # Retoma um job pausado
hermes cron run <job_id> # Dispara a execução na hora
hermes cron remove <job_id> # Apaga um job