Como um turno finalizado ensina o próximo. Um revisor propõe escritas duráveis de memória; um portão Validador dispõe; escritas aprovadas sedimentam no MemoryStore. A restrição-chave — tirada direto da matriz de fusão e do ADR-0018 — é que o aprendizado é com portão, não auto-aplicado. É um ADAPT do fork de background-review do Hermes para o estilo de portas do motor: sem thread de daemon, apenas três portas injetadas.
O driver é pequeno e total. Um summary vazio ou zero propostas faz curto-circuito para um resultado vazio (um passo no-op é válido); um erro do proposer ou do gate falha o passo inteiro fechado:
// packages/hermes/src/learning/review.ts:54-70 export const reviewAndLearn = async ( summary: string, deps: ReviewDeps, ): Promise<Result<LearnOutcome, Error>> => { if (summary.trim().length === 0) return ok(emptyOutcome()); const proposed = await deps.proposer(summary); if (!proposed.ok) return proposed; // erro do proposer ⇒ falha fechado if (proposed.value.length === 0) return ok(emptyOutcome()); const acc: OutcomeAcc = { applied: [], rejected: [], failed: [] }; for (const raw of proposed.value) { const stepErr = await processOne(raw, deps, acc); if (stepErr) return stepErr; // erro do gate / forma ruim ⇒ falha fechado } return ok({ applied: acc.applied, rejected: acc.rejected, failed: acc.failed }); };
failed ≠ rejectedO resultado separa três destinos. A distinção entre rejected (o portão disse não) e failed (o portão disse sim mas o store não conseguiu escrever) é estrutural para observabilidade — uma escrita acima do orçamento nunca pode ser confundida com uma recusa de política:
// packages/hermes/src/learning/review.ts:78-103 — processOne const parsed = reviewProposalSchema.safeParse(raw); // saída de modelo não confiável if (!parsed.success) return err(new Error(`Invalid review proposal: …`)); const proposal = parsed.data; const verdict = await deps.gate(proposal); if (!verdict.ok) return verdict; // ERRO do gate ⇒ falha o passo if (!verdict.value.approved) { acc.rejected.push({ proposal, reason: verdict.value.reason }); // portão disse NÃO return undefined; // continua o passo } const written = await deps.memory.apply(proposal.target, proposal.op); if (!written.ok) { acc.failed.push({ proposal, reason: written.error.message }); // store disse NÃO return undefined; // ainda continua } acc.applied.push(proposal);
processOne retorna Result<never, Error> | undefined. undefined significa "esta proposta foi tratada — continue o passo". Um Err significa "pare o passo inteiro fechado". Então uma única proposta sendo rejeitada ou com falha de store não aborta o lote; só um erro de infraestrutura (forma de proposta ruim, falha do gate) faz isso. O teste "records a store-rejected approved write in failed" prova que a primeira escrita sobrevive enquanto o estouro vai para failed.Até o Validator Gate real do @alembic/coda (ADR-0006) plugar seu próprio ReviewGate, o padrão conservador aprova uma proposta sse score ≥ 0.7 — a codificação mecânica de "aprender só com vitórias validadas" trazida do hermes-mini-loop:
// packages/hermes/src/learning/gate.ts:24-36 export const scoreThresholdGate = ( min: number = DEFAULT_REVIEW_SCORE_THRESHOLD, // 0.7 ): ReviewGate => { return async (proposal) => { const approved = proposal.score >= min; // fronteira INCLUSIVA const reason = approved ? `score ${proposal.score} ≥ threshold ${min}` : `score ${proposal.score} < threshold ${min} (learn only from validated wins)`; return ok({ approved, reason }); // puro & total: sempre ok(verdict) }; };
O gate retorna ok({approved:false, …}) para uma rejeição — não err. O envelope Result sinaliza se o gate funcionou; verdict.approved carrega a decisão. Um gate que deu erro (ex.: o serviço do Validador caiu) retorna err e para o passo. Essa separação é exatamente por que reviewAndLearn consegue distinguir "a política recusou isto" de "a máquina da política quebrou". O teste "rejects 0.69 and approves 0.70 at the default 0.7 floor" fixa a fronteira inclusiva.
O loop não adiciona nenhuma lógica de dedup própria. Escritas aprovadas fluem pelo dedup existente do MemoryStore — re-propor uma entrada que já existe é um no-op de sucesso (então é contado como applied, mas o store permanece em uma entrada). Isso espelha a intenção do ON CONFLICT DO UPDATE do mini-loop: reforçar, não empilhar duplicatas. O schema da proposta (learning/types.ts:62-71) valida a saída do modelo na fronteira, incluindo score limitado a [0,1] — um score: 1.5 de um modelo mal-comportado é rejeitado antes de qualquer escrita.
ok({approved:false, reason:'too weak'}) para uma proposta. Onde ela vai parar?ok({approved:false}) → rejected[]. failed[] é reservado para propostas que o gate aprovou mas o store não conseguiu escrever. E só um erro do gate (err) abortaria o passo.scoreThresholdGate() padrão. O resultado?approved = score >= min com min = 0.7. 0.69 está abaixo do piso inclusivo, então é rejeitada com a razão "learn only from validated wins". Exatamente 0.70 aprovaria.MemoryStore em vez de adicionar o seu próprio?applied) mas não cresce o store — provado pelo teste "reinforce, do not duplicate".score ≥ 0.7), e o Validador real do coda pode substituí-lo por injeção sem tocar neste kernel.AIAgent, então a mesma forma propor→portão→aplicar é remodelada em portas injetadas (ReviewProposer, ReviewGate, MemoryStore). A disciplina é idêntica; o encanamento serve ao motor.