O loop fechado auto-evolutivo
A Lição 3 nomeou o ciclo de aprendizado como a pedra angular da fusão. Aqui está como ele realmente é entregue: três subsistemas — memory, learning, curator — que juntos deixam uma run finalizada tornar a próxima mais esperta, sem nunca auto-escrever uma lição não validada na memória durável.
Esta lição destila docs/adr/0018-internalize-validator-gated-self-improvement-loop.md + o código real em packages/hermes/src/ + o docs/hermes-complete-map.md. Todo número e linha de código tem fonte no rodapé. Por que importa pra missão: é o mecanismo que faz o Alembic aprender entre runs — a diferença entre um agente que repete erros e um que sedimenta acertos.
- Explicar por que o snapshot de memória é congelado no início e o que isso faz pelo cache de prompt.
- Distinguir propor (o revisor) de dispor (o Validador) — a decisão central da ADR-0018.
- Rastrear uma proposta pelo kernel
reviewAndLearnaté um dos três baldes:applied/rejected/failed. - Justificar por que auto-aplicar foi rejeitado de propósito (o modo de falha da ADR-0006) e ler a máquina de estados do curador.
01 · Três partes, um loop
Antes do código, o modelo mental. "Auto-evolutivo" soa mágico, mas é um circuito de três peças concretas. Pense numa oficina que melhora entre turnos: ela trabalha com o caderno de notas do jeito que estava ao abrir de manhã (memory), no fim do dia um revisor propõe anotações novas e um inspetor decide quais entram (learning), e periodicamente alguém arruma a estante de ferramentas — sem jogar nada fora (curator). A oficina de amanhã abre com o caderno mais rico.
02 · Memory — o snapshot congelado
Dois stores limitados, em arquivo, persistem entre sessões: MEMORY.md (as próprias notas do agente) e USER.md (o que ele sabe sobre o usuário). Ambos são injetados no system prompt como um snapshot congelado no início da sessão. A analogia: é uma fotografia do caderno tirada quando a sessão abre. Você pode escrever no caderno durante o dia (e a tinta seca de verdade, no disco), mas a fotografia colada na parede não muda até você tirar uma nova — amanhã.
Por que congelar? O cache de prompt
Parece estranho não atualizar o snapshot na hora. A razão é puramente de engenharia, e vale entender:
Modelos cacheiam o prefixo do prompt: se o começo não muda, a parte cara já está computada. Se cada escrita reescrevesse o snapshot, o prefixo mudaria e o cache esquentaria do zero a cada nota. Por isso a disciplina:
- Escritas no meio da sessão vão para o disco imediatamente (duráveis) mas não mudam o snapshot — então o cache de prefixo de prompt fica quente a sessão inteira.
- O snapshot só se atualiza no próximo início de sessão (um load novo). É isso que torna "a próxima run é mais esperta" literal.
- Trazido da fonte do Hermes exatamente: uma op
memorycom ação ∈ {add, replace, remove}; replace/remove localizam o alvo por uma substring curta e única (sem IDs); entradas são delimitadas por§em sua própria linha; limites em caracteres, não tokens (independente do modelo).
// packages/hermes/src/memory/memory-store.ts:50-57 export const ENTRY_DELIMITER = '\n§\n'; /** Limite de caracteres padrão para o store MEMORY.md (padrão do Hermes). */ export const DEFAULT_MEMORY_CHAR_LIMIT = 2200; /** Limite de caracteres padrão para o store USER.md (padrão do Hermes). */ export const DEFAULT_USER_CHAR_LIMIT = 1375;
Este subsistema é um CLONE fiel de tools/memory_tool.py (1089 LOC). Os desvios são deliberados: IO é injetado via FsPort, e toda op falível retorna Result<T,Error> em vez de um dict Python. O mergulho completo no MemoryStore é a Lição 07.
Dois stores, dois limites
São dois arquivos distintos, com tetos de caracteres distintos — porque servem a propósitos distintos. As notas do agente cabem mais (2200); o que ele sabe do usuário é mais enxuto (1375):
A anatomia de uma op de memória
A op memory tem três ações, e o detalhe que importa: replace/remove acham o alvo por uma substring curta e única — sem IDs. As entradas ficam separadas por § numa linha própria:
03 · A escolha de design mais importante (ADR-0018)
O Hermes auto-escreve na memória após um turno. O Alembic não. O revisor apenas propõe; o Validador existente do Alembic dispõe. As escritas são com portão do Validador, nunca auto-aplicadas.
Em uma frase: propor ≠ dispor. Quem sugere uma lição (o revisor) não é quem decide se ela entra (o Validador). Em quase todo o resto a fusão clona o Hermes "quase literalmente"; aqui ela diverge de propósito. Por que a mudança? Duas razões do ADR, ambas fundamentadas:
- Não há
AIAgentem Python no Alembic para forkar como thread daemon — um passo síncrono pós-unidade sobre portas injetadas é a unidade certa, e compõe com o harness. - Mais importante, auto-escrever burlaria o Validator Gate e deixaria lições não validadas endurecerem na memória durável — exatamente o modo de falha que a ADR-0006 existe para prevenir ("nada sedimenta sem passar um piso de qualidade").
Então o ciclo são três portas injetadas e um kernel. As portas são as "tomadas" onde produção e teste plugam implementações diferentes — exatamente o padrão de injeção da Lição 05:
| Porta | Papel |
|---|---|
ReviewProposer | Retorna ReviewProposals a partir do resumo do turno — cada um um { target, op, rationale, score }. Em produção encapsula uma chamada de ModelAdapter; em testes, um fake. |
ReviewGate | Dispõe cada proposta (aprova/rejeita). O padrão é scoreThresholdGate(0.7); o Validador real do coda conecta depois fornecendo seu próprio gate — sem mudar o kernel. |
MemoryStore | O store onde escritas aprovadas se aplicam — reusando seu dedup, então rever um fato reforça em vez de duplicar. |
04 · O kernel reviewAndLearn
Aqui está o coração do learning — pequeno de propósito. Leia primeiro o fluxo de cada proposta, depois o código exato. A regra de ouro: tudo que pode falhar de verdade falha fechado (para o passo inteiro), mas uma rejeição normal não é falha — é um resultado esperado que vai para um balde.
// packages/hermes/src/learning/review.ts:54-69 — o kernel export const reviewAndLearn = async (summary, deps) => { if (summary.trim().length === 0) return ok(emptyOutcome()); // "Nada a salvar." const proposed = await deps.proposer(summary); if (!proposed.ok) return proposed; // erro do proposer → falha fechada if (proposed.value.length === 0) return ok(emptyOutcome()); const acc = { applied: [], rejected: [], failed: [] }; for (const raw of proposed.value) { const stepErr = await processOne(raw, deps, acc); // validar → portão → aplicar if (stepErr) return stepErr; // erro do portão → falha fechada } return ok({ applied: acc.applied, rejected: acc.rejected, failed: acc.failed }); };
Três baldes de resultado — applied / rejected / failed — então nada é descartado em silêncio. A saída do proposer é validada por Zod na fronteira (é saída de modelo não confiável em produção). Um erro de proposer ou portão falha o passo inteiro fechado; uma rejeição do store a uma escrita aprovada é registrada em failed, nunca lançada.
Rejeição (dado) ≠ erro (falha)
A distinção que confunde mais gente: uma proposta reprovada não é uma falha. Reprovar é resultado esperado e vem como ok(...); só uma quebra real (proposer ou portão estourando) vira err(...) e fecha o passo. Veja os dois mundos lado a lado:
reviewAndLearn retorna Result<LearnOutcome, Error>. Resumo vazio ou zero propostas → ok(emptyOutcome()) (curto-circuito barato). Um !proposed.ok propaga o erro tal e qual (fail-closed). processOne retorna Error | undefined: undefined = continua o laço (a proposta já foi rotulada num balde); um Error = falha o passo inteiro. Distinção crucial: rejeição de gate é dado (ok), erro de gate é falha (err).05 · O portão e o piso de 0.7
O portão padrão é deliberadamente simples — um check puro de score. "Com portão" não quer dizer "humano no loop"; quer dizer "tem que passar um piso de qualidade". O piso padrão é 0.7:
// packages/hermes/src/learning/gate.ts:24-36 — o portão conservador padrão export const scoreThresholdGate = (min = DEFAULT_REVIEW_SCORE_THRESHOLD) => { return async (proposal) => { const approved = proposal.score >= min; // limite inclusivo: score === min aprova const reason = approved ? `score ${proposal.score} ≥ threshold ${min}` : `score ${proposal.score} < threshold ${min} (learn only from validated wins)`; return ok({ approved, reason }); // puro + total: ok(verdict) para toda entrada }; };
O limite padrão é 0.7 — a codificação mecânica da regra do hermes-mini-loop "aprender só com vitórias validadas". Note que a decisão vive em verdict.approved, não no Result: uma rejeição é um ok(...) normal, não um erro.
proposal.score >= min — o >= torna o limite inclusivo: score === min passa. Se você chutou "rejeitada", provavelmente leu como > (estrito). O comentário no código deixa explícito: "limite inclusivo: score === min aprova". É um detalhe pequeno mas é justamente o tipo de fronteira que um teste de unidade trava — 0.7 aprova, 0.699… não.A escala de score, lado a lado com o piso
Veja onde o piso corta. Tudo de 0.7 pra cima aprova (verde); abaixo, rejeita (clay) — e rejeitar é normal, não erro:
scoreThresholdGate(min) recebe o piso. O 0.7 é só o padrão conservador. Depois, o Validador completo do coda pode plugar um gate inteiramente diferente (uma rodada de Council, por exemplo) sem tocar uma linha do kernel — a porta ReviewGate é a costura. É o mesmo padrão "injete o comportamento, não o codifique" da seção 03.06 · Siga uma proposta de ponta a ponta (passo a passo → agora você)
Você viu o fluxograma e o código. Agora rode-os na mão com um exemplo concreto, devagar — depois um caso é seu. Recuperar o procedimento (não só ver o veredito pronto) é o que fixa.
summary do turno não é vazio → o kernel não curto-circuita. (Resumo vazio retornaria ok(emptyOutcome()) — "Nada a salvar".)deps.proposer(summary) retorna duas propostas: P1 { op:'add', score:0.82 } e P2 { op:'add', score:0.55 }. A chamada deu ok (sem erro de modelo) → segue.processOne. Zod valida o formato ✓ → portão: 0.82 >= 0.7 ✓ aprova → o store aceita ✓ → vai para applied.processOne. Zod valida ✓ → portão: 0.55 >= 0.7 ✗ → ok({approved:false}) → vai para rejected. Não é erro: o laço continua normalmente.ok({ applied:[P1], rejected:[P2], failed:[] }). Uma escrita sedimentou; uma foi barrada pelo piso; nada falhou. O snapshot da próxima sessão incluirá P1.score:0.9, passa o portão, mas o MemoryStore rejeita a escrita (digamos, estouraria o limite de caracteres). Em qual balde ela cai — applied, rejected ou failed? Decida antes de revelar.
failed. P3 foi aprovada (não é rejected, que é só para o portão dizer "não"), mas a aplicação no store não deu certo — e a regra é "uma rejeição do store a uma escrita aprovada é registrada em failed, nunca lançada". A pegadinha: rejected = barrada pelo portão; failed = aprovada mas o store não gravou. E nenhum dos dois lança — só um erro de proposer ou de portão faz o passo inteiro falhar fechado.07 · Com portão vs auto-aplicar — a opção rejeitada de propósito
A ADR-0018 não escolheu "com portão" por inércia: ela considerou e rejeitou a alternativa óbvia — auto-aplicar (o comportamento literal do Hermes). Veja os dois caminhos lado a lado. Repare onde o caminho de cima contorna o gate, e por que isso é justamente o que a ADR-0006 proíbe:
08 · Curator — a metade do descarte
Memory e learning são a metade que acumula. O curador é a metade que mantém limpo. O agente cria skills; telemetria de uso se acumula; o curador é o passo determinístico que arruma a biblioteca. É um CLONE fiel de agent/curator.py:apply_automatic_transitions, com quatro regras clonadas exatamente:
- Portão de proveniência: apenas skills
createdBy === 'agent'são tocadas; o resto é pulado. - Isenção de pin: uma skill
pinnednunca é transicionada, em nenhum caminho. - Nunca deletar: o estado terminal é
archived— "ação máxima = arquivar". Não há remoção. - As quatro transições: active/stale além do corte de arquivamento → archived; active além do corte de stale → stale; uma skill stale usada de novo → reativada para active.
O tempo é um Clock injetado — nunca Date.now() (a regra de determinismo do motor, e o que torna os testes de transição reprodutíveis). O curador é o mesmo Clock com que o usage store foi construído, então um evento registrado "agora" e uma transição decidida "agora" concordam.
As guardas, antes das transições
Antes de decidir qualquer transição, o curador filtra. Duas guardas eliminam a maioria das skills sem nem olhar para datas — é o que impede o curador de mexer no que não é dele:
A máquina de estados
Passadas as guardas, restam quatro transições entre três estados. Repare: não há nenhuma seta saindo para "deletado" — o estado terminal é archived, e uma skill stale pode voltar para active se for usada de novo:
O mergulho completo no UsageStore + curador (a telemetria que alimenta as datas) é a Lição 09.
09 · O ciclo de vida, acumular vs descartar (lado a lado)
As duas metades do loop fechado seguem a mesma filosofia conservadora, mas em direções opostas. Veja-as na mesma tabela — uma sedimenta conhecimento sob portão; a outra poda skills sem nunca apagar:
| Dimensão | Learning (acumula) | Curator (descarta) |
|---|---|---|
| O que move | Notas de memória (MEMORY.md/USER.md) | Estado de skills (active/stale/archived) |
| Gatilho | Resumo do turno (pós-unidade) | Telemetria de uso + passagem do tempo |
| O filtro | ReviewGate (piso 0.7 padrão) | Guardas: proveniência (agent) + pin |
| Decisão | Probabilística (modelo propõe, gate dispõe) | Determinística (Clock injetado, sem aleatório) |
| Ação máxima | Escrita aprovada (durável) | Arquivar — nunca deletar |
| Reversível? | Dedup reforça em vez de duplicar | stale → active (reativa se reusada) |
10 · Como isso se encaixa
Você dissecou o loop por dentro. Agora dê um passo atrás: onde ele liga no resto da máquina? O loop fechado não é um apêndice — é o que acontece depois que uma run termina e antes que a próxima comece. Ele se pluga na ponta de saída do pipeline (uma run finalizada, já passada pelos gates) e alimenta a ponta de entrada da run seguinte (o snapshot de memória mais rico).
Onde você está na metodologia: as Lições 01–03 montaram o motor, a engenharia reversa do Hermes e a matriz de fusão; as Lições 14–19 detalham a cintura estreita, o funil, os gates, o Council e o swarm que executam uma run. Esta Lição 04 é a peça que fecha o circuito por cima de tudo isso — ela transforma o resultado de uma run executada (sob os gates) no combustível da próxima. Para ver as 30 peças no mesmo mapa e como cada uma liga na seguinte, abra o mapa interativo da metodologia (ou volte ao hub do curso).
- Lição 07 · MemoryStore — o store que este loop lê no início (snapshot congelado) e onde as escritas aprovadas pousam; aqui você só viu a forma, lá você vê o dedup, os delimitadores § e os limites em caracteres por dentro.
- Lição 08 · reviewAndLearn — o kernel "propor → portão → aplicar" desta seção, esmiuçado: as três portas injetadas, os três baldes e a validação Zod na fronteira, linha a linha.
- Lição 09 · UsageStore + curador — a outra metade do loop (a do descarte): a telemetria de uso que alimenta as datas e a máquina de estados active⇄stale→archived que poda sem nunca deletar.
- Lição 17 · A pipeline de gates — o que vem ANTES (upstream): é o Validator Gate dessa pipeline que o loop reusa para "dispor" — por isso auto-aplicar foi rejeitado, pra não contornar este portão (a ligação com a ADR-0006).
11 · Na prática
Chega de diagrama — eis o loop como comandos reais. O passe de aprendizado é opt-in: uma run normal não aprende. Você liga a peça desta lição com a flag --learn numa run com escopo (ADR-0018). O passe roda depois que os gates passam, e é não-fatal — se ele falhar, a run verde continua verde.
# Liga o passe de aprendizado pós-run (opt-in, ADR-0018). # --offline mantém tudo hermético e $0 (adapter offline determinístico). alembic run --goal GOAL.md --plan alembic.plan.ts --learn --offline --yes # … a run executa as units e passa os gates como sempre … # então, no fim, o passe de aprendizado roda e imprime UMA linha: # learning gate: 1 applied, 1 rejected # (evento de log: run:learning-pass-done { applied:1, rejected:1, failed:0 }) # as escritas APROVADAS sedimentam no MEMORY.md / USER.md do dir da run.
Fonte da forma exata: apps/cli/src/index.ts:117 (a flag) e apps/cli/src/commands.ts:1364-1390 — o passe só roda sob --learn, dispõe pelo gate do Validador (gate:{kind:'validator'}) e escreve learning gate: N applied, M rejected. Sem --learn, nada disso acontece: o comportamento da run é idêntico ao de antes.
E para inspecionar a memória multi-store que o agente acumula (episódica, semântica, procedural, de decisão, de transcrição), o comando é alembic memory. Liste um substore para ver o que já sedimentou:
# Lista os registros de um substore (newest-first), com filtros opcionais. alembic memory decision list --limit 3 # saída (uma linha de cabeçalho + uma por registro): # memory decision: 2 record(s) under .alembic/memory/decision.jsonl # dec-01J… [agent cli] at 2026-06-27T16:00:00.000Z # dec-01J… [agent cli] at 2026-06-27T15:58:00.000Z
Fonte: apps/cli/src/commands.ts (runMemory) — list imprime memory <substore>: N record(s) under <path> e uma linha <id> [agent <agente>] at <timestamp> por registro; os substores são episodic | semantic | procedural | decision | transcript (CLAUDE.md). Atenção à camada: este alembic memory é o store multi-substore append-only em JSONL; é parente — não idêntico — ao MEMORY.md/USER.md de snapshot da seção 02, que é o store que o --learn sedimenta. Os dois são memória, em granularidades diferentes.
- Clone e entre no repo, depois garanta o build verde:
git clone <repo> alembic && cd alembic·pnpm -r typecheck && pnpm -r build && pnpm -w test - Gere um escopo mínimo (cria
GOAL.md+alembic.plan.ts+ contrato):alembic plan "um passo trivial que só roda um teste" - Rode com o passe de aprendizado, hermético:
alembic run --goal GOAL.md --plan alembic.plan.ts --learn --offline --yes - O que procurar na saída: a linha
learning gate: N applied, M rejectedperto do fim (sem ela, ou você esqueceu--learn, ou não há adapter de proposer). - O que procurar no dir da run: abra
.alembic/runs/<run-id>/e veja oMEMORY.md/USER.md— só as escritas que passaram o piso do Validador estão lá. Depois rodealembic memory decision listpara inspecionar o store multi-substore. - Prove o opt-in: rode a MESMA run sem
--learn. Nenhuma linhalearning gate:aparece, e nenhumMEMORY.mdé escrito pelo passe — a peça desta lição fica desligada por padrão.
--learn ao alembic run. (É a peça inteira desta lição, ligada por uma flag.)--offline: o passe usa o adapter offline determinístico como proposer (commands.ts:1365).alembic memory <substore> list (ex.: decision, episodic).alembic run --goal GOAL.md --plan alembic.plan.ts --yes (sem --learn) e a run ficou verde, mas nenhuma linha learning gate: apareceu e o MEMORY.md não mudou. Bug ou esperado? Decida antes de revelar.
--learn, o bloco em commands.ts:1364 (if (args.learn)) nem executa — a run se comporta exatamente como antes da ADR-0018. Por que opt-in: aprender é um efeito durável sobre a memória; ligar por padrão faria toda run mexer no snapshot da próxima. A disciplina é "conservador por padrão" — a mesma do gate 0.7 e do curador que nunca deleta.Fixe os conceitos (flashcards)
Clique pra virar. Tente lembrar a resposta antes de virar — recuperação ativa fixa mais que reler.
applied (escreveu) · rejected (portão barrou, ok normal) · failed (store recusou, não lança). Nada some em silêncio.archived é terminal) — nunca deletar. E só toca skills createdBy:'agent' e não pinned.Revisão cumulativa — recupere de memória
Antes de clicar: responda de cabeça. As opções têm tamanho parecido de propósito — sem pista pela forma.
score: 0.6 e o portão padrão está em uso. O que acontece?scoreThresholdGate(0.7) retorna ok({approved:false, reason}) — uma rejeição é um resultado normal, não um erro. A proposta cai em rejected e o laço segue. a e c confundem "rejeição" com "erro": só um erro de proposer ou de portão (não uma reprovação de score) falha o passo fechado. b inventa um caminho "escreve mas sinaliza" que não existe — abaixo do piso, não escreve. O humano não está no loop do gate padrão; o piso é um check puro.pinned: true e createdBy: 'user'. O que ele faz?createdBy === 'agent' (esta é 'user'), e skills pinned nunca são transicionadas. a ignora as duas guardas — a data nem é consultada. b viola a regra terminal: o estado máximo é archived, não há caminho de delete nenhum. d aplica a transição errada (reativação é só para stale usada de novo) e, de novo, esta skill nem chega à máquina de estados.AIAgent-daemon como razão para o passo síncrono, não como impossibilidade. c inventa um custo que não é o argumento do ADR. d inverte a verdade: o Hermes auto-escreve na memória após um turno — é justamente o comportamento que o Alembic divergiu de propósito.ok e não erro?", "O que o coda Validator pluga no lugar do gate 0.7?", "Como o dedup do MemoryStore reforça em vez de duplicar?". É só dizer.