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
| Arquivo | Papel |
|---|---|
cron/jobs.py | Modelo de job, armazenamento, leitura/escrita atômica no jobs.json |
cron/scheduler.py | Laço do agendador — detecção de job vencido, execução, controle de repetições |
tools/cronjob_tools.py | Registro e handler da ferramenta cronjob exposta ao modelo |
gateway/run.py | Integração com o gateway — o tique do cron dentro do laço longo |
hermes_cli/cron.py | Subcomandos hermes cron da CLI |
Modelo de agendamento
Há suporte a quatro formatos de agendamento:
| Formato | Exemplo | Comportamento |
|---|---|---|
| Atraso relativo | 30m, 2h, 1d | Uma vez só, dispara depois da duração indicada |
| Intervalo | every 2h, every 30m | Recorrente, dispara em intervalos regulares |
| Expressão de cron | 0 9 * * * | Sintaxe cron padrão de 5 campos (minuto, hora, dia, mês, dia da semana) |
| Timestamp ISO | 2025-01-15T09:00:00 | Uma 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
| Estado | Significado |
|---|---|
scheduled | Ativo, vai disparar no próximo horário agendado |
paused | Suspenso — não dispara até ser retomado |
completed | A contagem de repetições acabou, ou era de uma vez só e já disparou |
running | Executando 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
InProcessCronSchedulerembutido, que roda o laço histórico dentro do processo chamandoscheduler.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 emplugins/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):
| chave | significado |
|---|---|
cron.provider | chronos para ativar (vazio = tiquetaque embutido) |
cron.chronos.portal_url | URL base da Nous (armar + emitir o token de disparo) |
cron.chronos.callback_url | a URL base pública do próprio gateway, para os disparos que chegam |
cron.chronos.expected_audience | a audiência do token de disparo deste agente |
cron.chronos.nas_jwks_url | conjunto 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
cronjobfica 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:
- As skills são carregadas na ordem indicada
- O conteúdo do SKILL.md de cada uma é injetado como contexto
- O prompt do job é acrescentado como a instrução da tarefa
- 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:
- Sobrescrita no nível do módulo —
_SCRIPT_TIMEOUT(para testes e monkeypatching). Só é usada quando difere do padrão. - Variável de ambiente —
HERMES_CRON_SCRIPT_TIMEOUT - Configuração —
cron.script_timeout_secondsnoconfig.yaml(lida viaload_config()) - 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) oufallback_model(dicionário antigo) doconfig.yaml, seguindo o mesmo padrão do_load_fallback_model()do gateway. É passado comofallback_model=para oAIAgent.__init__, que normaliza os dois formatos numa cadeia de reserva. - Pool de credenciais — carregado por
load_pool(provider)deagent.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>.
| Alvo | Sintaxe | Exemplo |
|---|---|---|
| Chat de origem | origin | Entrega no chat onde o job foi criado |
| Arquivo local | local | Salva em ~/.hermes/cron/output/ |
| Telegram | telegram, telegram:<chat_id>, telegram:<chat_id>:<thread_id>, telegram:@username | telegram:-1001234567890:17585 |
| Discord | discord, discord:#channel, discord:<channel_id>, discord:<channel_id>:<thread_id> | discord:#engineering |
| Slack | slack, slack:#channel, slack:<channel_id>, slack:<channel_id>:<thread_ts> | slack:#engineering |
| Matrix | matrix, matrix:<!room_id:server>, matrix:<@user:server> | matrix:!abc123:example.org |
| Feishu | feishu, feishu:<chat_id>, feishu:<chat_id>:<thread_id> | feishu:oc_abc123def |
whatsapp, whatsapp:<jid>, whatsapp:+<E.164> | whatsapp:123456@g.us | |
| Signal | signal, signal:group:<id>, signal:+<E.164> | signal:group:aBcD== |
| SMS | sms, sms:+<E.164> | sms:+<número E.164> |
email, email:<endereço> | email:alertas@exemplo.com | |
| Weixin | weixin, weixin:<wxid> | weixin:wxid_abc123 |
| Mattermost | mattermost ou mattermost:<channel_id> | O nome sozinho entrega no canal padrão do Mattermost |
| Home Assistant | homeassistant ou homeassistant:<conversation> | O nome sozinho entrega na conversa do HA |
| DingTalk | dingtalk ou dingtalk:<chat_id> | O nome sozinho entrega no DingTalk |
| WeCom | wecom ou wecom:<chat_id> | O nome sozinho entrega no WeCom |
| BlueBubbles | bluebubbles ou bluebubbles:<chat_guid> | O nome sozinho entrega no iMessage via BlueBubbles |
| QQ Bot | qqbot ou qqbot:<chat_id> | O nome sozinho entrega no QQ (Tencent) pela API oficial v2 |
| Bot Chat | bot-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