Passo 5 · O loop · O loop · Loop do agente & guardrails
Curso de Harness Engineering · Visual Course

Loop do agente & guardrails

Você vai entender por que, quando a unidade de trabalho — "fábrica de petições" — entra no swarm, ela não pode correr para sempre: o Alembic não conta passos, ele limita a forma do trabalho. Profundidade (MAX_DEPTH=2), re-litígio (MAX_LOOPS=3), o DAG que esvazia, o timeout do subprocesso e o park T4 — mais a VM que recusa Date.now() e o roteador que degrada sem nunca lançar exceção.

Leia primeiro (fonte primária)
HARNESS-MAP.md · Parte II §11–§12 e Parte V §22 — "Agent guardrails + loop/tool budgets + termination"

Esta lição destila os princípios nº 11/12/22 do mapa e os ancora em código real: packages/swarm/src/{orchestrator,park,process}.ts, packages/council/src/verifier.ts, packages/vm/src/run-plan.ts, packages/harness/src/funnel.ts. Leitura complementar do founder: learn-harness-engineering (PT-BR) e ai-engineering-from-scratch.

Leia a versão simples, ou abra a camada técnica em qualquer seção.
Suposições tolas (o que assumimos de você)
  • Você já viu as lições 0001–0004: sabe que o Alembic é o harness, que toda chamada de modelo passa pela cintura estreita (ModelAdapter + Result), e que a saída é validada com Zod nos boundaries.
  • Você sabe o que é um loop e o que é uma função recursiva — só isso.
  • Você não precisa conhecer "ReAct", "DAG" ou "circuit breaker" de cor; cada um é explicado quando aparece.
Ao fim desta lição você será capaz de
  • Explicar por que o Alembic não tem um contador maxIterations — e o que ele usa no lugar.
  • Nomear as quatro terminações estruturais: MAX_DEPTH, MAX_LOOPS, o DAG que esvazia, o timeout/buffer do subprocesso — e o park T4 como o portão humano.
  • Descrever a VM determinística: o que ela recusa (Date.now(), new Date(), Math.random()) e por quê (cache + resume).
  • Distinguir fallback silencioso de degraded-mode honesto: por que o roteador devolve err e o funil degrada o tier sem lançar.
  • Reconhecer o gap honesto: não há teto para um loop ReAct aberto — o DAG limitado substitui.
1

A grande ideia


Um agente autônomo que pode chamar a si mesmo e chamar modelos é um foguete sem corte de combustível: sem limites, ele queima dinheiro, gira em círculos, ou faz algo irreversível. O trabalho do harness é instalar os guardrails — as cercas que garantem que o loop sempre para, e para do jeito certo.

O detalhe surpreendente do Alembic: ele não conta passos. Não existe um maxIterations = 50 em lugar nenhum. Em vez disso, o Alembic limita a forma do trabalho. A unidade "fábrica de petições" não é um loop livre que pergunta "o que faço agora?" indefinidamente — ela é um grafo finito de tarefas (um DAG). Um grafo finito sempre esvazia. Isso é terminação por construção, não por relógio.

Pense como… uma esteira de fábrica, não um hamster na roda. A roda do hamster pode girar para sempre — você precisa de um cronômetro para pará-la. A esteira tem um começo e um fim: quando a última peça sai, a esteira para sozinha. O Alembic transforma o agente de hamster em esteira. Onde a analogia quebra: a esteira é uma linha reta; o DAG pode ter ramos paralelos (um líder abre vários operários) — mas continua finito.

✗ agente sem cercas ✓ agente cercado (Alembic) gira, gasta, repete — sem saída $ ↑ · loop ∞ · irreversível? grafo finito → última peça → para $ limitado · loop limitado · park
À esquerda, um loop aberto sem cercas espirala sem fim. À direita, o modelo do Alembic: um grafo finito que termina quando a última peça sai. A diferença é a tese da lição.

Por baixo do capô

A terminação no Alembic é estrutural, não um step-counter (HARNESS-MAP.md §11). São quatro mecanismos somados, cada um em um arquivo diferente: (a) o plano é um DAG finito de units[]/milestones[] — o drain roda while (!queue.isComplete()) e cada lote move ≥1 tarefa a um estado terminal (orchestrator.ts:294); (b) o BudgetGuard bloqueia a próxima chamada paga antes que o teto seja rompido (etl/budget.ts); (c) o park T4 retém trabalho irreversível/legal/de segurança da execução autônoma (swarm/park.ts); (d) cada subprocesso tem um timeout e um teto de buffer (swarm/process.ts).

O gap honesto: para um loop ReAct aberto (o estilo "pensa → age → observa → repete" sem grafo prévio), faltaria um teto maxIterations/maxToolCalls. O Alembic não precisa dele porque seu modelo é um DAG limitado — mas se um dia ele expusesse um agente livre, precisaria adicionar um. Honestidade vale como engenharia: nomeie a fronteira do seu modelo.

2

A moldura (as 3 camadas)


Os guardrails desta lição vivem todos na camada Harness — a única das três camadas que o Alembic possui. A moldura "Self-Learning Agents" do founder separa: Model (os pesos, delegado), Harness (o loop/gates/routing, possuído) e Context (memória, composto). O loop e seus limites são território de casa.

Self-Learning Agents — onde os guardrails moram Model os pesos · inferência DELEGADO gateway + MLX Harness loop · gates · routing POSSUÍDO ← esta lição MAX_DEPTH · MAX_LOOPS · DAG · park VM determinística · degraded-mode Context memória · skills COMPOSTO (lição 0006) o sinal do usuário (o veredito humano não realimenta) — gap da lição 0008
Da esquerda → direita: o Alembic delega o Model, possui o Harness (onde os guardrails moram) e compõe o Context. O loop e seus limites são casa.
Guarde isto Tudo nesta lição é Harness: a camada que o Alembic implementa em TypeScript. O modelo é uma caixa-preta atrás da cintura; os guardrails são o que impede essa caixa-preta de virar uma motosserra sem trava.
A cross-section diagram of an autonomous agent loop drawn as a single running track shaped like a closed circuit, with five distinct gate-posts placed along it, each a clearly diff
A cross-section diagram of an autonomous agent loop drawn as a single running track shaped like a closed circu
3

O loop em uma imagem


Aqui está o caminho da unidade "fábrica de petições" através do loop, com os guardrails como portões. Repare: cada portão é uma forma de parar — ou de continuar com segurança.

pedido "fábrica de petições" park? T4 / irrev. PARK ledger + humano DAG: ready? deps satisfeitas depth<2? canSpawn worker (leaf) adapter + proof budget? bloqueia próxima done / ship drain: enquanto há ready (cada lote esvazia ≥1) → o grafo termina
O loop do Alembic = drenar um DAG finito sob portões. Cada losango é uma forma de PARAR; o laço pontilhado é o drain, que esvazia o grafo até ele acabar.
O insight central: não há um relógio contando passos. O loop para porque o grafo acaba (cada lote move ≥1 tarefa ao terminal), porque o orçamento bloqueia a próxima chamada paga, porque a profundidade impede um operário de virar líder, ou porque o trabalho foi parqueado para um humano.
4

As 4 terminações estruturais


Vamos abrir cada uma das quatro cercas (mais o park, a quinta e mais dura). Pense nelas como motivos diferentes pelos quais o loop não corre para sempre — e repare que nenhum é "contei 50 passos, paro".

espaço MAX_DEPTH = 2 tempo MAX_LOOPS = 3 topologia DAG drain esvazia recursos subprocesso 120s · 10MiB autonomia park T4 o humano cinco travas, cinco tipos de limite — nenhuma conta passos
As cinco travas em uma linha, cada uma limitando uma dimensão diferente: espaço, tempo, topologia, recursos e autonomia. Vamos abrir cada uma.

4.1 · MAX_DEPTH = 2 — o limite de profundidade

O Alembic tem três papéis em uma hierarquia: orquestrador (profundidade 0) → líder (1) → operário (2). Um orquestrador pode abrir líderes; um líder pode abrir operários; um operário é uma folha — ele faz o trabalho e nunca abre ninguém. A regra que garante isso é uma linha: canSpawn = depth < MAX_DEPTH. Com MAX_DEPTH = 2, a árvore não pode crescer infinitamente para baixo.

depth 0 orquestrador canSpawn ✓ depth 1 líder líder depth 2 operário ▪ operário ▪ operário ▪ canSpawn ✗ (folha)
A escada de profundidade: orquestrador → líder → operário. Em depth 2, canSpawn é falso — o operário é folha e a árvore para de crescer. (swarm/types.ts:25,32)
packages/swarm/src/orchestrator.ts:107 & packages/swarm/src/types.ts:24–32
// types.ts — a escada e o teto numa constante só
export const ROLE_DEPTH = { orchestrator: 0, lead: 1, worker: 2 };
export const MAX_DEPTH = 2;

// orchestrator.ts — o limite de recursão, em uma linha:
export const canSpawn = (depth: number): boolean => depth < MAX_DEPTH;

// e em runtime, fail-closed se um líder estiver fundo demais:
if (subtasks.length > 0 && !canSpawn(depth)) {
  return err(new Error(`task '${task.id}' has subtasks but is at max depth`));
}

Por baixo do capô

O orquestrador drena no nível do líder (ROLE_DEPTH.lead = 1); uma tarefa que carrega subtasks roda como um sub-run filho uma profundidade abaixo (executeOne(task, ctx, depth + 1)). Quando esse depth + 1 chega a 2, canSpawn vira falso: uma tarefa com subtasks nesse nível é um erro fail-closed, nunca uma recursão silenciosa. O efeito: a árvore tem no máximo três níveis, então o número total de nós é limitado pela largura, não pela profundidade. Limitar profundidade é o que impede "um agente que abre um agente que abre um agente…" — a forma de runaway mais cara.

Antes de revelar — adivinhe

Um operário (profundidade 2) recebe uma tarefa que, por engano, carrega subtasks. O que o Alembic faz?

Devolve um err fail-closed: "task '…' has subtasks but is at max depth 2 (a leaf cannot spawn)". Ele não ignora as subtasks silenciosamente nem tenta abri-las assim mesmo. A regra é dura: na dúvida, falhe alto. (orchestrator.ts:343)

4.2 · MAX_LOOPS = 3 — o limite de re-litígio

MAX_DEPTH limita o espaço (quão fundo a árvore vai). MAX_LOOPS limita o tempo (quantas vezes a mesma decisão pode ser re-julgada). O verificador (o "checker" do padrão maker-checker) decompõe uma decisão do conselho em claims atômicos e prova cada um com um oráculo determinístico. Se a mesma decisão já foi re-litigada mais de 3 vezes (loopCount > MAX_LOOPS), o verificador para de re-decidir e escala para T4 — entrega ao humano em vez de girar para sempre.

verifica loopCount 1 re-litiga loopCount 2 re-litiga loopCount 3 > 3? escala T4 park escalate-after-N: re-litigar é barato 3×; na 4ª, o humano decide. cada verificação é um oráculo PURO: mesma evidência → mesmo veredito
O verificador escala-após-N: 3 re-julgamentos no máximo; na 4ª tentativa, parkedTier = escalateTier(T3) = T4. O loop de decisão não pode girar para sempre. (council/verifier.ts:49,233)
packages/council/src/verifier.ts:49 & 216–243
export const MAX_LOOPS = 3 as const;

export const verifyDecision = (options: VerifyOptions): VerificationReport => {
  // ... prova cada claim com seu oráculo determinístico ...
  const loopCount = options.loopCount ?? 0;
  if (loopCount > MAX_LOOPS) {
    // escala ao topo da escada (T4) e PARA de re-decidir.
    const parkedTier = escalateTier(Tier.T3);
    return { verdict: 'needs-review', proofs, provenCount, parkedTier };
  }
  return { verdict: verdictFromProofs(proofs), proofs, provenCount };
};
Detalhe técnico O verificador é read-only por arquitetura: ele só aceita visões readonly (o DebateResult do maker e o ContextPack), não tem adapter, não tem registry, não pode re-rodar um modelo nem editar um voto. Ele só inspeciona evidência estruturada (votos, scores, spread, quórum) e emite um veredito. É isso que o torna um checker, não um segundo maker. Os oráculos nunca leem a prosa do maker — só os números.
T0 local $0 · silencioso T1 → T2 T3 conselho T4 — PARK conselho + humano escalateTier sobe 1 degrau; o topo é o humano autonomia DESCE conforme o risco SOBE — T4 nunca roda sozinho
A escada de tiers (TIER_LADDER): escalateTier sobe um degrau; quando o verificador escala de T3, chega a T4 — o park humano, o topo da escada. (contracts/tier.ts:42,68)

4.3 · O DAG que esvazia — terminação por construção

Esta é a terminação mais sutil e a mais importante. O drain roda enquanto houver tarefas ready (com dependências satisfeitas). A cada volta ele pega um lote, executa, e aplica o resultado. A garantia de terminação está em uma frase: cada lote move pelo menos uma tarefa para um estado terminal (done/failed/parked). Como o grafo é finito, o número de estados terminais é finito — então o loop tem que acabar.

packages/swarm/src/orchestrator.ts:294–311
const drain = async (queue, ctx, depth) => {
  while (!queue.isComplete()) {
    const batch = queue.readyTasks().slice(0, ctx.maxConcurrency);
    if (batch.length === 0) break; // só sobra bloqueado-por-falha; pare.
    const outcomes = await Promise.all(batch.map((t) => executeOne(t, ctx, depth)));
    for (const outcome of outcomes) await commitOutcome(queue, outcome.value, ctx);
  }
};
blocked deps pendentes dep ok ready elegível running um lote done ■ failed ■ parked ■ terminal (absorvente)
Os estados terminais (■ done/failed/parked) são absorventes — uma tarefa que chega lá não volta. Como o grafo é finito e cada lote empurra ≥1 tarefa para um deles, o drain sempre converge.
Por que isto sempre termina: ou (a) o grafo completa (isComplete() vira true), ou (b) só restam tarefas bloqueadas por uma dependência que falhou — e aí batch.length === 0 dispara o break. Não há terceiro caso. Sem relógio, sem contador: a topologia do grafo garante o fim. Isto é o equivalente de engenharia da "esteira que para quando a última peça sai".
Cuidado Esta garantia depende de cada lote realmente mover algo ao terminal. Se uma tarefa pudesse voltar de running para ready indefinidamente, o loop giraria. O Alembic só faz essa demoção uma vez, na recuperação de órfãos no resume (recoverOrphans) — não dentro do drain. É um detalhe que a leitura do código revela e a intuição esconde.

4.4 · Timeout + teto de buffer — a cerca do subprocesso

Quando um operário roda um comando externo (hoje operações de git worktree; amanhã builds e testes), esse comando é um processo filho que poderia travar ou inundar a memória. runProcess instala duas cercas duras: um timeout de relógio de parede (padrão 120s) que mata o filho, e um teto de buffer (padrão 10 MiB) de stdout/stderr — um filho desgovernado não pode pendurar nem causar OOM em um run AFK de horas.

packages/swarm/src/process.ts:42–45 & 57–90
export const DEFAULT_PROCESS_TIMEOUT_MS = 120_000;      // 120s
export const DEFAULT_PROCESS_MAX_BUFFER = 10 * 1024 * 1024; // 10 MiB

// argv array (NUNCA string de shell — sem superfície de injeção),
// timeout, AbortSignal, e teto de buffer. Honra never-throws:
execFile(file, [...args], {
  cwd, timeout: opts.timeoutMs ?? DEFAULT_PROCESS_TIMEOUT_MS,
  maxBuffer: opts.maxBuffer ?? DEFAULT_PROCESS_MAX_BUFFER,
  signal: opts.signal, killSignal: 'SIGTERM',
}, (error, stdout, stderr) => {
  if (error && typeof error.code === 'number')  // rodou e saiu ≠0 → é um processo FINALIZADO
    resolve(ok({ code: error.code, stdout, stderr }));
  else if (error)                                // ENOENT/ETIMEDOUT/ABORT/ENOBUFS → não rodou até o fim → err
    resolve(err(toError(error)));
});
execFile termina (error, out, err) typeof error.code? number ok({ code, stdout, stderr }) rodou e finalizou (mesmo saída ≠ 0) string err(toError(error)) ENOENT · ETIMEDOUT · ABORT · ENOBUFS
O discriminador é typeof error.code: número → o processo rodou e finalizou → ok; string → não rodou até o fim → err. Nunca um throw. (process.ts:76–84)
Dica Repare na sutileza da never-throws: um processo que terminou com saída ≠ 0 ainda é ok (tem um code numérico — ele rodou). Só uma falha de spawn, timeout, abort ou overflow de buffer (códigos-string) é err. O discriminador é typeof error.code: número = finalizou; string = não rodou até o fim. Saída ≠ 0 é um sinal, não um crash.

4.5 · O park T4 — o portão humano (a 5ª trava, e a mais dura)

As quatro terminações acima fazem o loop parar. O park T4 faz algo diferente: ele garante que certo trabalho nunca corra sozinho. Qualquer tarefa marcada como T4, irreversível, ou com marcador legal/segurança é roteada para o ledger (t4-parked.jsonl, append-only) e exige conselho + adjudicação humana antes de poder rodar. E o detalhe de ouro: o tier padrão é T4 — então trabalho não-classificado faz park por padrão. Na dúvida, park.

TaskSpec tier · flags · meta classifyPark legal/sec/irrev/T4? sim t4-parked.jsonl + conselho + humano undefined → roda execução autônoma T1–T3 (auto) DEFAULT_TIER = T4 não-classificado → park por padrão
classifyPark decide: marcadores legal/security/irreversible (nessa ordem de precedência), depois a regra T4. Retornar undefined = elegível para autônomo. (swarm/park.ts:38–45)
packages/swarm/src/park.ts:38–49 & packages/contracts/src/tier.ts:51
// a precedência: o motivo mais específico vence (entra no ledger).
export const classifyPark = (spec: TaskSpec): ParkReason | undefined => {
  for (const [key, reason] of REASON_KEYS)        // legal, security, irreversible
    if (spec.metadata[key] === true) return reason;
  if (spec.irreversible) return 'irreversible';
  if (spec.tier === Tier.T4 || isParked(spec.tier)) return 'tier-t4';
  return undefined;                              // elegível para execução autônoma
};

// tier.ts — na dúvida, park:
export const DEFAULT_TIER: Tier = Tier.T4;
O park é uma fronteira de segurança, não uma heurística
O comentário no código é explícito: "This is a hard safety boundary, not a heuristic: when in doubt, park." Petições jurídicas reais — protocolar no tribunal, assinar, enviar a um cliente — são exatamente o tipo de trabalho irreversível que o T4 retém da máquina. O humano é o último portão, e ele é obrigatório por padrão, não opcional.

Comparativo · as 5 travas lado a lado

TravaLimita o quêConstante / regraOnde mora
MAX_DEPTHProfundidade da árvore (espaço)2 · canSpawn = depth<2swarm/types.ts:32
MAX_LOOPSRe-litígio da mesma decisão (tempo)3 · loopCount>3 → T4council/verifier.ts:49
DAG drainTotal de trabalho (topologia)cada lote esvazia ≥1 → terminalswarm/orchestrator.ts:294
SubprocessoTempo + memória de um filho120s · 10 MiBswarm/process.ts:42
Park T4Autonomia (o humano decide)DEFAULT_TIER = T4swarm/park.ts:38
A vertical funnel diagram showing five differently shaped filters stacked from wide top to narrow bottom, illustrating how many possible actions are progressively narrowed to one s
A vertical funnel diagram showing five differently shaped filters stacked from wide top to narrow bottom, illu
5

A VM determinística


Há um guardrail de natureza diferente das cinco travas: ele não para um loop em runtime — ele recusa o plano antes de executá-lo. A VM determinística lê o módulo alembic.plan.ts e o rejeita se ele usar Date.now(), new Date() ou Math.random(). Por quê? Porque um plano que depende da hora ou do acaso não pode ser cacheado nem retomado — rode-o duas vezes e você terá dois resultados diferentes, e o cache + o resume quebram.

Pense como… a regra de uma receita de bolo publicada: "não escreva 'asse até parecer pronto'". Se a receita diz "tire às 14h32 de hoje", ninguém consegue repeti-la amanhã. A VM exige que o plano seja uma receita reproduzível: mesmas entradas → mesmo bolo, sempre. Onde quebra: a receita pode usar um timer (uma duração relativa); o que ela não pode é cravar um instante absoluto.

plan module .ts ou .js .ts → regex sweep checkDeterminismTs .js → AST Acorn checkDeterminism proibidos Date.now() new Date() Math.random() limpo → roda achou → err
Dois caminhos, uma regra: .ts usa regex (Acorn não parseia TypeScript), .js usa AST Acorn. Ambos recusam os mesmos três construtos. Fail-closed. (vm/run-plan.ts:16–28,53–66)
packages/vm/src/run-plan.ts:16–28
// Para .ts, um sweep de regex (conservador: pode pegar dentro de comentário,
// mas um falso-positivo é mais seguro que deixar não-determinismo passar).
const forbidden = [
  { pattern: /\bDate\.now\s*\(/,        name: 'Date.now()' },
  { pattern: /\bnew\s+Date\s*\(\s*\)/,  name: 'new Date()' },
  { pattern: /\bMath\.random\s*\(/,     name: 'Math.random()' },
];
for (const { pattern, name } of forbidden)
  if (pattern.test(source))
    return { ok: false, reason: `Non-deterministic construct detected: ${name}` };

A VM é a "fiscal da reprodutibilidade". Se seu plano tenta saber a hora ou jogar um dado, ela barra na porta e diz exatamente qual construto encontrou. Isso é o que permite o cache (rodar de novo = mesmo resultado, então o resultado anterior vale) e o resume (retomar um run do meio sem refazer o que já passou).

Há duas verificações porque há dois formatos de plano. Para .js, checkDeterminism usa a AST do Acorn — preciso, não pega ocorrências em comentários/strings. Para .ts, Acorn não parseia a sintaxe TypeScript, então o sweep de regex entra como rede de segurança intencionalmente conservadora. A assimetria é deliberada: prefere-se um falso-positivo (barrar um Date.now() dentro de um comentário) a um falso-negativo (deixar passar um real). Em ambos os caminhos, achar um construto proibido faz o runPlan devolver { ok: false } — fail-closed antes de qualquer import do módulo.

plano determinístico mesma entrada CACHE: SHA-256 estável 2ª vez = mesma chave → acerta → $0 reusa ✓ RESUME: retoma do meio já-feito vale → não refaz continua ✓ Date.now()? chave muda → nunca acerta
Por que a VM importa: um plano determinístico produz a mesma chave SHA-256 (o cache acerta) e permite retomar do meio (o resume). Um Date.now() mudaria a chave a cada vez — o cache nunca acertaria.
Guarde isto Determinismo não é purismo — é o que torna cache e resume possíveis. Um plano que muda a cada execução não tem como ser retomado de onde parou. A VM transforma "deveria ser reproduzível" em "é reproduzível, ou não roda".
6

Routing, fallback & degraded-mode


O último pedaço do loop é a escolha do modelo e o que acontece quando algo dá errado. Aqui o Alembic toma uma decisão contraintuitiva e forte: ele recusa fazer fallback silencioso. Se não há modelo para um tier, o roteador devolve um err tipado e deixa o chamador decidir escalar — ele não troca por um modelo mais barato sem avisar.

Pense como… um farmacêutico honesto. Faltou o remédio da marca que o médico prescreveu? Um farmacêutico desonesto entrega um genérico parecido sem dizer nada (fallback silencioso). O honesto avisa: "não tenho este; quer falar com o médico sobre uma alternativa?" (devolve err, o chamador escala). A segunda postura é mais trabalhosa, mas nunca esconde uma regressão de custo ou capacidade.

✗ fallback silencioso ✓ o jeito do Alembic pede T3 T3 indisponível troca p/ T1 calado regressão de custo/capacidade ESCONDIDA — ninguém soube pede T3 T3 indisponível err → chamador escala falha ALTO; a decisão de escalar é explícita e visível
O roteador no-silent-fallback: ele nunca substitui um modelo caladamente. Devolve err (no_model_for_tier / adapter_not_registered) e o chamador decide escalar via escalateTier. (HARNESS-MAP.md §12)

Degraded-mode: degradar sem nunca lançar

"No-silent-fallback" não significa "estoure na cara do usuário". Significa: quando o orçamento bloqueia uma chamada paga, o funil degrada o tier — mantém o sinal não-refinado — em vez de lançar uma exceção. O loop continua, só com menos qualidade naquele item, e conta quantos itens foram bloqueados. Degradação graciosa, fail-closed, never-throws.

packages/harness/src/funnel.ts:433–464
// T2: refina os sinais mais fortes via chamada FRONTIER, com checagem de
// orçamento fail-closed antes de CADA chamada paga. Um bloqueio degrada
// o tier (o sinal fica não-refinado) em vez de lançar.
for (const batch of chunk(shortlist, opts.batchSize)) {
  const check = opts.budget.check({ modelId: route.pricingModelId, usage: estimateUsage(input) });
  if (!check.ok) {
    blocked += 1;                          // conta o item degradado
    logAt(logger, 'warn', 'funnel T2 budget-blocked', { spentUsd: check.spentUsd });
    continue;                             // NÃO roda a chamada paga; segue o loop
  }
  const result = await route.adapter.run(input);   // cabe no teto → roda
  meterSpend(opts.budget, route.pricingModelId, result);
}
teto US$100 — o guard projeta a PRÓXIMA chamada antes de gastar já gasto US$55 +US$20 cabe ✓ +US$30 romperia ✗ teto chamada que cabe → roda + mede o gasto real chamada que romperia → bloqueada, sinal degradado pré-flight, não pós-fato: o gasto é evitado ANTES de acontecer · T0 = $0 nunca bloqueia
O BudgetGuard é pré-flight: ele projeta o custo da próxima chamada e a bloqueia antes de gastar se o teto seria rompido. Nada é desperdiçado. (etl/budget.ts:131–145)
Três comportamentos em uma cena: (1) o BudgetGuard bloqueia a próxima chamada antes de gastar (pré-flight, não pós-fato); (2) T0/local é sempre $0 e nunca bloqueado — o substrato grátis sempre roda, então há sempre um caminho de degradação; (3) o bloqueio vira um contador (t2BudgetBlocked) no relatório, não um crash. O sinal sobrevive não-refinado; o run continua.
4 sinais entram no T2 → sinal 1 · refinado ✓ sinal 2 · refinado ✓ sinal 3 · orçamento ✗ segue NÃO-refinado sinal 4 · refinado ✓ run continua t2BudgetBlocked = 1 um item degrada, o loop NÃO trava — never-throws, fail-closed
Degraded-mode em ação: o sinal 3 estoura o orçamento e segue não-refinado; os demais refinam, o contador sobe, e o run continua. Degradar nunca é o mesmo que estourar.
Detalhe técnico A peça que ainda falta (o gap honesto do mapa, §12) é o degraded-mode UX: uma superfície visível ao usuário dizendo "rodando em um modelo mais fraco". A política de roteamento é forte e correta (falha-alto, sem substituição silenciosa); o que não existe ainda é o aviso visual de que uma degradação aconteceu. Política ≠ UX — nomeie a diferença.
A side-by-side comparison illustration with a clear vertical divider. On the left, a tangled endless spiral of arrows feeding back into itself with no exit, labelled open loop, con
A side-by-side comparison illustration with a clear vertical divider. On the left, a tangled endless spiral of
7

Cartões de memória


Vire cada cartão (clique, ou Enter/Espaço) e tente responder antes de ver o verso. Recuperar da memória fixa melhor que reler.

terminação
Quantos passos o Alembic conta antes de parar o loop?
clique para virar
Nenhum. Não há maxIterations. A terminação é estrutural: o DAG esvazia, o orçamento bloqueia, a profundidade limita, o park retém.
profundidade
O que canSpawn = depth < 2 garante?
clique para virar
Que um operário (depth 2) é folha e não abre ninguém. A árvore orquestrador→líder→operário não cresce além de 3 níveis.
re-litígio
O que acontece quando loopCount > 3 no verificador?
clique para virar
Ele para de re-decidir e escala para T4 (parkedTier = escalateTier(T3)). O humano assume; o loop de decisão não gira para sempre.
determinismo
Quais 3 construtos a VM recusa, e por quê?
clique para virar
Date.now(), new Date(), Math.random(). Porque quebram cache e resume: um plano não-determinístico não pode ser retomado.
park
Qual é o DEFAULT_TIER, e o que isso implica?
clique para virar
T4. Trabalho não-classificado faz park por padrão — fica retido para conselho + humano. Na dúvida, park.
degraded
Quando o orçamento bloqueia uma chamada paga, o funil faz o quê?
clique para virar
Degrada o tier: mantém o sinal não-refinado, conta o item bloqueado, e continua — never-throws. T0 grátis nunca é bloqueado.
8

Exemplo resolvido


Vamos seguir a unidade "fábrica de petições" por um run que estressa todos os guardrails de uma vez, passo a passo.

Cenário: a unidade "fábrica de petições" sob pressão
1
Classificação. A unidade "protocolar petição no tribunal" entra. classifyParkirreversible: true → retorna 'irreversible' → vai para o t4-parked.jsonl. Ela nunca roda sozinha. As sub-unidades de geração de minuta (T2) seguem.
2
DAG. O líder "gerar petições" abre 5 operários (depth 1 → 2). Cada operário é folha — canSpawn(2) é falso, nenhum pode abrir mais. O drain roda enquanto há ready.
3
Orçamento. No 3º operário, o BudgetGuard projeta que a próxima chamada frontier romperia o teto. check.ok é falso → o item é degradado (sinal não-refinado), t2BudgetBlocked += 1, o loop continua. Sem crash.
4
Subprocesso. Um operário roda git worktree add. O comando trava. Aos 120s, runProcess mata o filho (ETIMEDOUT) → devolve err. A tarefa vira failed, não pendura o run.
5
Verificador. O conselho debate uma decisão de GO. O resultado fica ambíguo e é re-litigado. Na 4ª vez (loopCount = 4 > 3), o verificador escala: parkedTier = T4. Humano decide.
6
Terminação. Sobram só tarefas bloqueadas-por-falha → batch.length === 0break. O drain retorna. O run termina, com um checkpoint, sem nunca ter "contado passos".
Agora você tente: se a unidade não tivesse marcador irreversible nem tier explícito, em qual tier ela cairia — e o que aconteceria? (Dica: DEFAULT_TIER.) Resposta: T4 → park por padrão. Na dúvida, o humano decide.
9

Experimente — o simulador de terminação


Mexa nos controles. Este simulador aplica as mesmas regras do código: canSpawn = depth < 2, o teto de orçamento, e loopCount > 3 → T4. Veja o veredito mudar de RODA para PARA em tempo real — e por quê.

Simulador · uma unidade tentando rodar
orçamento40%
re-litígio1/3
RODA — a unidade é elegível para execução autônoma.
Dica Tente: profundidade 2 + subtasks sim (a folha não pode abrir) · gasto 105 (rompe o teto → degrada) · re-litígios 4 (> 3 → escala T4). Cada um é uma trava diferente disparando.

Experimente também — o verificador de determinismo

Cole (ou escolha um exemplo) um trecho de alembic.plan.ts. Este verificador roda o mesmo sweep de regex da VM (vm/run-plan.ts) e diz se a VM aceitaria ou recusaria o plano — e qual construto a barrou.

Verificador de determinismo · cole um plano
ACEITO — nenhum construto não-determinístico encontrado. A VM rodaria este plano.
Você é o professor agora: que outra trava você adicionaria a um agente que pode escrever no disco do cliente — e em qual das cinco ela se pareceria mais? Na próxima lição (0006 · Contexto, memória & RAG) saímos do loop e entramos na camada Context: como o agente decide o que sabe — e o gap honesto de que o Alembic indexa mas não recupera.
10

Comparativo · loop aberto vs DAG limitado


A escolha de design mais importante desta lição: o Alembic não é um loop ReAct aberto. Entender a diferença é entender por que ele não precisa de um contador de passos — e onde estaria o gap se ele um dia precisasse.

Loop ReAct ABERTO (o que o Alembic NÃO é)

"Pensa → age → observa → repete" sem um grafo prévio. O agente decide o próximo passo a cada volta. Pode girar para sempre → precisa de um teto maxIterations/maxToolCalls explícito como única defesa. Flexível, mas a terminação é frágil (depende de um número bem escolhido).

DAG LIMITADO (o que o Alembic É)

Um grafo finito de units[]/milestones[] decidido antes de rodar. O drain esvazia o grafo; cada lote move ≥1 ao terminal. Termina por construção → não precisa de contador. Menos flexível em runtime, mas a terminação é estrutural e provável.

O gap honesto (HARNESS-MAP.md §11): "there is no maxIterations/maxToolCalls ceiling on an open-ended agent loop, because Alembic's model is a bounded DAG, not an open ReAct loop." Se o Alembic algum dia expuser um agente livre, ele precisará adicionar esse teto. Hoje, o cap de orçamento + a finitude do DAG substituem. Nomear a fronteira do seu modelo é engenharia honesta.

Comparativo · never-throws no subprocesso

Situação do filhoerror.codeResultadoPor quê
Rodou e saiu 0ok({ code: 0 })Sucesso normal
Rodou e saiu ≠ 0númerook({ code: n })Finalizou — saída ≠ 0 é um sinal, não um crash
Comando não existeENOENT (string)errNão rodou
Estourou 120sETIMEDOUTerrNão rodou até o fim
Estourou 10 MiBENOBUFSerrMorto por proteção de memória
11

Recapitulando (deck)


Os seis pontos que ficam. Avance com ou os botões.

A tese

Terminação por forma, não por relógio

O Alembic não conta passos. Ele limita a forma do trabalho: um DAG finito sempre esvazia. Esteira, não roda de hamster.

Espaço

MAX_DEPTH = 2

orquestrador → líder → operário. canSpawn = depth < 2. O operário é folha: a árvore não cresce para baixo sem fim.

Tempo

MAX_LOOPS = 3

A mesma decisão re-litigada mais de 3 vezes → o verificador escala para T4. O loop de decisão não gira para sempre.

O portão humano

Park T4 · na dúvida, park

DEFAULT_TIER = T4. Irreversível/legal/segurança/não-classificado → ledger + humano. A trava mais dura.

Reprodutibilidade

A VM recusa o acaso

Sem Date.now(), new Date(), Math.random() no plano. Regex p/ .ts, AST Acorn p/ .js. É o que torna cache + resume possíveis.

Date.now()new Date()Math.random()

Confiabilidade

Degrada, nunca esconde

Roteador no-silent-fallback: devolve err, o chamador escala. Orçamento estourou? Degrada o tier (sinal não-refinado), never-throws.

1 / 6setas
12

Teste seu entendimento


Revisão cumulativa

Três perguntas. Sua pontuação acumula abaixo.

1 · Por que o Alembic não tem um contador maxIterations no loop principal?
A terminação é estrutural: o drain roda enquanto há ready e cada lote move ≥1 tarefa ao terminal num grafo finito (orchestrator.ts:294). O gap honesto: um loop ReAct aberto precisaria de um teto — o DAG limitado o substitui.
2 · No verificador, o que acontece quando loopCount ultrapassa MAX_LOOPS (3)?
Escala-após-N: re-litigar é barato até 3 vezes; na 4ª, o verificador entrega ao humano em vez de girar para sempre (verifier.ts:233). Ele é read-only — não pode re-rodar modelo nem editar voto, só inspecionar evidência.
3 · Quando o BudgetGuard bloqueia uma chamada paga no funil T2, o que ocorre?
Degraded-mode: o bloqueio degrada o tier (sinal não-refinado), incrementa t2BudgetBlocked, e faz continue (funnel.ts:454). T0/local é $0 e nunca bloqueado, então há sempre um caminho de degradação. É fail-closed sem lançar.
Acertos: 0/3
13

As Dez · regras do loop confiável


As dez regras dos guardrails do Alembic
  1. Não conte passos; limite a forma. Um DAG finito termina por construção — sem maxIterations.
  2. Limite a profundidade. canSpawn = depth < 2: um operário é folha e nunca abre outro agente.
  3. Limite o re-litígio. A mesma decisão 3× no máximo; na 4ª, escale para o humano (T4).
  4. Cerque cada subprocesso. Timeout (120s) + teto de buffer (10 MiB): nenhum filho pendura ou causa OOM.
  5. Na dúvida, park. DEFAULT_TIER = T4: o não-classificado e o irreversível ficam para o humano.
  6. Recuse o acaso no plano. Sem Date.now()/new Date()/Math.random() — ou não há cache nem resume.
  7. Nunca substitua um modelo em silêncio. Devolva err; deixe o chamador escalar explicitamente.
  8. Degrade, não estoure. Orçamento no limite → mantenha o sinal não-refinado e conte o item; never-throws.
  9. Saída ≠ 0 é um sinal, não um crash. Um processo que finalizou é ok; só não-rodou é err.
  10. Nomeie sua fronteira. Diga em voz alta o que seu modelo NÃO cobre (o teto de loop aberto) — honestidade é engenharia.