Lição 10 · Curso de Fusão · Parte 2 · Mergulhos profundos · Clarificação profunda
Curso de Fusão · Parte 2 · Mergulho profundo · subsistema 4 de 7

Clarificação profunda — o ClarifyGateway, o T4 human-gate

Quando o agente precisa de uma decisão humana antes de seguir, ele levanta uma pausa estruturada: uma pergunta (múltipla escolha ou aberta) e bloqueia esperando a resposta. Em termos do Alembic, esta é a superfície do T4 human-gate (ADR-0005). Python bloqueia uma thread num threading.Event; o Node não tem thread bloqueante, então o equivalente fiel é uma promise + um registry de resolvers + um timeout. É um CLONE do clarify_tool.py + clarify_gateway.py do Hermes.

Cada seção tem um modo Simples e um Técnico. Abra os que quiser.
Suposições tolas (o que presumimos de você)
  • Você já viu uma Promise em JavaScript — sabe que ela resolve mais tarde.
  • Você sabe o que é um Map (uma tabela chave → valor).
  • Você leu (ou vai ler) as lições anteriores sobre Result<T, Error> e determinismo. Tudo o que importa aqui é re-explicado.
Ao fim desta lição você consegue
  • Explicar por que uma pausa humana vira promise + registry + timeout num runtime single-thread.
  • Ler o ciclo askpendingresolve/timeout e dizer onde cada estado vive.
  • Distinguir o que o schema valida do que só o gateway pode validar (campo cruzado).
  • Justificar a regra deixa-pendente — por que uma resposta inválida não cancela a pergunta.
  • Apontar as duas decisões de determinismo (id monotônico, sem Math.random()) e o timer.unref().
01

A grande ideia

Imagine um cozinheiro no meio de uma receita. Numa etapa irreversível — "jogo o vinho inteiro ou metade?" — ele para e chama o chef. Ele não chuta. Ele segura a panela e espera uma resposta. Se o chef sumir por tempo demais, o cozinheiro tem uma regra: passado o limite, abandona a etapa em vez de ficar parado para sempre.

O ClarifyGateway é exatamente esse cozinheiro disciplinado. Quando o agente chega numa decisão que só um humano deve tomar — fazer ship, gastar dinheiro, sobrescrever algo — ele levanta uma pergunta estruturada e bloqueia até a resposta chegar. Essa é a quarta peça que portamos do Hermes: o T4 human-gate.

Analogia: o gateway é uma caixa de senha numerada num balcão. Você tira uma senha (ask), seu pedido fica numa estante de pendentes (o Map), e quando o atendente chama o seu número e te entrega a resposta (resolve), você sai da fila. Se ninguém te atender até o fechamento, um alarme (o timeout) tira você da estante para você não esperar a noite inteira.

Por que isto é interessante de portar

A fonte (tools/clarify_gateway.py, 278 LOC) bloqueia uma thread do agente num threading.Event e a resolve a partir do callback de botão / interceptação de texto da plataforma, com um timeout para que um usuário silencioso nunca prenda o guarda de agente-rodando para sempre. O Node não tem thread bloqueante: o event loop é único. O equivalente fiel é, portanto, uma Promise retornada (o que o chamador aguarda) + um Map<id, pending> que deixa um callback da plataforma resolver por id + um setTimeout que garante que nunca trava.

O estado vive na instância (espelhando o dict module-level _entries do Python) para que um adaptador de plataforma possa resolver por id sem uma referência de volta a quem perguntou. Em termos do Alembic, este é o caminho de dados do T4 human-gate (ADR-0005): a pausa fail-closed que por padrão faz T4-park.

Python (fonte) thread do agente parada em threading.Event event.set() ⇒ acorda + timeout no poll Node (este clone) Promise retornada o chamador faz await resolve(id) ⇒ settle + setTimeout (nunca trava) mesmo contrato
O threading.Event do Python e a Promise+registry do Node são o mesmo contrato: bloqueie até a resposta, com um limite de tempo.
Guarde istoBloquear aqui nunca significa parar uma thread no Node. Significa devolver uma Promise que o chamador aguarda — e ter um jeito (id + timeout) de sempre encerrá-la.
tira a senha (ask) estante de pendentesa senha nº esperaMap<id, pending> chamado sai (resolve) fechamento alarme tira (timeout)
A analogia em uma figura: a senha entra na estante e só sai quando é chamada (resolve) — ou quando o alarme do fechamento (timeout) a remove.
02

O mecanismo — ask registra, resolve encerra

Três verbos contam toda a história. ask recebe a pergunta, valida, cunha um id, arma um timer e devolve a promise. A entrada fica viva numa estante — o Map<id, pending>. Depois, ou a plataforma chama resolve(id, resposta) e a promise encerra (settle), ou o timer dispara e a entrada é descartada com um err.

Diagrama do ClarifyGateway: a caixa ask registra um pending no Map entries, resolve encerra pela plataforma, e um timeout tracejado apaga a entry e resolve err, com selo de timer.unref

O threading.Event do Python vira promise + registry de resolvers + timeout no Node. ask registra; resolve encerra; o timeout nunca deixa travar.

ask(pergunta) valida · cunha id · arma timer entries: Map<id, pending> { id, question, settle, timer } estado na instância register resolve(id, resp) callback da plataforma valida vs pergunta → settle(ok) timeout dispara delete entry · resolve(err) timer.unref() ⇒ o processo pode sair
Um registro, dois desfechos: uma resposta válida encerra; o timeout descarta. Nada fica preso.
packages/hermes/src/clarify/gateway.ts:82–110 — ask (condensado)
const parsed = clarifyQuestionSchema.safeParse(question);
if (!parsed.success) return err(new Error(`Invalid clarify question: …`)); // falha-fechado SÍNCRONO
const id = this.mintId();
return new Promise((resolvePromise) => {
  const timer = setTimeout(() => {
    this.entries.delete(id);                       // descarta a entrada…
    resolvePromise(err(new Error('clarify timed out'))); // …nunca trava
  }, timeoutMs);
  timer.unref?.();                                  // deixa o processo sair enquanto pendente
  const settle = (result) => { clearTimeout(timer); this.entries.delete(id); resolvePromise(result); };
  this.entries.set(id, { id, question: valid, settle, timer });
});

Dois detalhes que importam

(1) Uma pergunta inválida falha fechado de forma síncrona — antes de qualquer entrada ser registrada, então uma pergunta malformada nunca deixa um pedido pendente pendurado (teste: "returns err for a >MAX_CHOICES question and registers nothing"). (2) timer.unref() deixa o processo Node sair mesmo com um clarify pendente — um prompt humano travado não mantém o runtime vivo para sempre.

Preveja antes de revelar

Uma pergunta chega malformada (por exemplo, com 5 opções). Em que momento ela é rejeitada — e o que sobra na estante de pendentes?

É rejeitada já dentro do ask, de forma síncrona, pelo safeParse do schema — antes de cunhar um id ou registrar qualquer coisa. A estante fica vazia: pending() retorna []. Falhar fechado > truncar silenciosamente uma entrada não confiável.
Sob o capôO settle guardado na entrada faz três coisas atômicas: clearTimeout(timer), entries.delete(id) e resolvePromise(result). É por isso que um segundo resolve no mesmo id falha — a entrada já não existe.
ask(pergunta) safeParse ok safeParse falha cunha id · arma timer · registraentries.set(id, …) · devolve Promise return err — SÍNCRONOregistra NADA · pending() = []
A bifurcação dentro do ask: uma pergunta válida registra a entrada; uma inválida falha fechado antes de registrar — por isso o teste "registers nothing".
03

O contrato de dados — escolha ou aberta, no teto de 4

Uma pergunta é uma união discriminada no campo kind. Uma pergunta choice carrega de 1 a MAX_CHOICES opções; MAX_CHOICES = 4 é o teto de dados. A 5ª opção "Outro" que a UI mostra é uma preocupação de apresentação — não é modelada aqui.

Pense em dois moldes de pergunta. Um molde escolha: "qual ambiente?" com botões staging / prod. Um molde aberta: "por quê?" com um campo de texto. O campo kind diz qual molde é, e o resto da forma muda conforme isso.

packages/hermes/src/clarify/types.ts:48–61
export const clarifyQuestionSchema = z.discriminatedUnion('kind', [
  z.object({
    kind: z.literal('choice'),
    prompt: z.string().min(1, 'prompt cannot be empty'),
    choices: z.array(z.string().min(1, …))
      .min(1, 'choice question needs at least one choice')
      .max(MAX_CHOICES, `choice question allows at most ${MAX_CHOICES} choices`),
  }),
  z.object({ kind: z.literal('open'), prompt: z.string().min(1, …) }),
]);
ClarifyQuestion discriminada em kind kind: 'choice' kind: 'open' choice prompt: string (não vazio) choices: string[] .min(1) · .max(4) = MAX_CHOICES teto de DADOS (não a UI) open prompt: string (não vazio) sem opções predefinidas a resposta é texto livre o 5º "Outro" mora na UI
Um kind, dois formatos. O teto de 4 é uma regra de dados; a UI acrescenta o "Outro" por conta própria.
Por que uma união discriminada? Porque ela deixa o TypeScript e o Zod estreitarem o tipo: quando kind === 'choice', o compilador sabe que choices existe; quando é 'open', sabe que não. Erros de forma viram erros de compilação, não bugs em produção.
04

Tolerância a dicts — coerceChoices

Modelos (LLMs) às vezes desobedecem o formato. Em vez de devolver opções como textos simples (["staging", "prod"]), devolvem dicionários ([{description: "…"}]). Um ajudante robusto chamado coerceChoices — clone do _flatten_choice da fonte — desembrulha esses dicts por chaves de rótulo canônicas, em ordem de prioridade.

entrada bruta do modelo { description: "Fazer ship" } CHOICE_LABEL_KEYS (em ordem) label description ✓ text title name · value → EXCLUÍDOS (não são rótulos) "Fazer ship" sem chave canônica ⇒ '' (é descartado)
Ordem de desembrulho: label → description → text → title. A primeira chave com texto não vazio vence; name/value ficam de fora.
packages/hermes/src/clarify/types.ts:104–121 — flattenChoice
const CHOICE_LABEL_KEYS = ['label', 'description', 'text', 'title'] as const; // name/value FORA
// string ⇒ trim; dict ⇒ 1ª chave canônica não vazia; senão ⇒ '' (descartado)
const flattenChoice = (raw) => {
  if (raw == null) return '';
  if (typeof raw === 'string') return raw.trim();
  for (const key of CHOICE_LABEL_KEYS) {
    const value = raw[key];
    if (typeof value === 'string' && value.trim().length > 0) return value.trim();
  }
  return '';
};

name/value são deliberadamente excluídos — eles carregam valores de enum / identificadores crus, não rótulos humanos, e um rótulo-lixo é pior que nenhuma opção (o dict colapsa para '' e é descartado). O teste prova: coerceChoices(['', ' ', null, undefined, {name:'n'}, {value:'v'}, {}]) retorna [].

CuidadocoerceChoices não corta para 4. Cortar é trabalho do schema, então uma lista longa demais falha fechado na validação em vez de ser truncada em silêncio. Limpeza e teto são responsabilidades separadas.
Flashcard
Qual chave vence em {label:'win', text:'lose'}?
clique para virar
label — é a primeira em CHOICE_LABEL_KEYS. A ordem é label → description → text → title; a primeira não vazia ganha.
Flashcard
O que vira {name: 'n'} depois do coerceChoices?
clique para virar
Vira '' e é descartado. name/value são excluídos por carregarem identificadores crus, não rótulos.
Flashcard
Quem corta a lista para 4 opções?
clique para virar
O schema (.max(MAX_CHOICES)), não o coerceChoices. Lista >4 falha fechado na validação.
05

Validação de campo cruzado — a resposta tem que caber na pergunta

O Zod consegue checar a forma de uma resposta, mas não se ela cabe na pergunta viva. É o resolve que impõe os invariantes cruzados — o kind bate e o índice está no intervalo — que o schema sozinho não enxerga.

camada 1 · schema (Zod) a FORMA está certa? kind ∈ {choice, open} index é int ≥ 0 · text não vazio passou camada 2 · gateway CABE na pergunta viva? resp.kind === pergunta.kind index < choices.length
O schema sabe se a resposta é bem-formada. Só o gateway sabe se ela responde a esta pergunta.
packages/hermes/src/clarify/gateway.ts:143–168 — validateResponse
if (value.kind !== question.kind)
  return err(new Error(`Response kind '${value.kind}' does not match question kind '${question.kind}'.`));
if (value.kind === 'choice' && question.kind === 'choice') {
  if (value.index >= question.choices.length)
    return err(new Error(`Choice index ${value.index} out of range …`));
}

Repare na guarda dupla value.kind === 'choice' && question.kind === 'choice': ela estreita os dois lados para que o TypeScript saiba que question.choices existe e value.index é um número. Pura, nunca lança.

Exemplo resolvido — uma resposta de escolha fora do intervalo
1
A pergunta viva é choiceQ(['a', 'b']) — 2 opções, índices válidos 0 e 1.
2
Chega resolve('q-1', { kind: 'choice', index: 2 }). O schema aprova: index é um inteiro ≥ 0. Forma boa.
3
O gateway compara com a pergunta: 2 >= choices.length (2) ⇒ verdadeiro. Retorna err("out of range").
4
A entrada fica pendente: pending() ainda lista ['q-1']. Uma resposta corrigida (índice 0 ou 1) ainda pode chegar.
5
Agora você: a pergunta tem 3 opções e chega index: 3. Aprova no schema? Passa no gateway? (Sim no schema; não no gateway — 3 >= 3. Fica pendente.)
06

A regra deixa-pendente — uma falha pensada

Aqui está a decisão mais sutil e mais bonita do subsistema. Quando o resolve recebe uma resposta inválida (kind errado, índice fora do intervalo), ele retorna err mas deixa a entrada pendente — então uma resposta corrigida ainda pode chegar e encerrar a mesma promise. Só uma resposta válida encerra (e remove) a entrada.

Fluxograma com três losangos — id existe, kind/index válidos, settle — mostrando que uma resposta inválida retorna err mas deixa a entrada pendente para uma correção, e um selo de double-resolve falha

Rejeitar nunca cancela a pergunta. Só uma resposta válida (ou o timeout) encerra a entrada — uma inválida fica pendente esperando a correção.

Estado da entrada q-1: PENDENTE · estante: ['q-1']

Clique uma ação acima para ver o que acontece com a entrada e com a estante.
Demo fiel à máquina real: inválida ⇒ err + segue pendente; válida ⇒ settle + sai da estante; depois disso, resolve de novo ⇒ err (já não existe).
packages/hermes/src/clarify/clarify.test.ts:124–135 — kind-mismatch leaves pending
const pending = gw.ask(choiceQ(['a', 'b']));
const r = gw.resolve('q-1', { kind: 'open', text: 'nope' });
expect(r.ok).toBe(false);                     // kind não bate
expect(gw.pending()).toEqual(['q-1']);      // FICA pendente
expect(gw.resolve('q-1', { kind: 'choice', index: 0 }).ok).toBe(true); // correção encerra

Por contraste, um id desconhecido ou já-encerrado é um err simples ("Unknown or already-resolved"), e um double-resolve falha porque o primeiro já removeu a entrada — a 1ª resposta é a que encerrou a promise.

Por que isto é um design melhor

Cancelar a pergunta numa resposta errada seria punir o usuário por um erro de digitação ou um clique torto: ele perderia o contexto e teria que recomeçar. Deixar pendente trata a resposta inválida como o que ela é — uma tentativa que não serviu — e mantém a porta aberta para a próxima. A única coisa que encerra à força é o timeout, e mesmo ele resolve de forma limpa, sem travar.

DicaLeia "deixa-pendente" como idempotência amigável: você pode tentar responder quantas vezes precisar; só a resposta certa "gruda". É o mesmo espírito de um formulário que mostra o erro e mantém o que você já preencheu.
plataforma gateway (entry q-1) resolve(q-1, 'first') ok ⇒ settle + entries.delete(q-1) resolve(q-1, 'second') err ⇒ Unknown or already-resolved
Double-resolve em linha do tempo: o 1º resolve válido é o que encerra a promise e remove a entrada; o 2º não encontra mais nada e retorna err.
07

Determinismo — ids monotônicos, sem Math.random()

Os ids vêm de uma fábrica injetável que, por padrão, é um contador monotônico — nunca Math.random() / Date.now(), que a VM de planos do motor rejeita e que quebrariam o replay. Injetar a fábrica também deixa os testes nomearem q-1, q-2.

monotonicIdFactory() let n = 0; n += 1; `${prefix}-${n}` clarify-1 clarify-2 clarify-3 replay reproduzível a VM de planos proíbe random/Date
Um contador injetável troca o id aleatório da fonte por algo reproduzível — a mesma disciplina do Clock injetado do curador.
o timeout em números
0
DEFAULT_CLARIFY_TIMEOUT_MS (ms) = 600s = 10 min

Espelha o clarify_timeout de 600s da fonte. Os testes dirigem com fake timers do vitest, avançando além do prazo para provar que a entrada é descartada e a promise resolve err.

a fronteira do timeout (teste)
−1 mspendente +1 mserr · removida limite = 600000
packages/hermes/src/clarify/gateway.ts:176–182
export const monotonicIdFactory = (prefix = 'clarify') => {
  let n = 0;
  return () => { n += 1; return `${prefix}-${n}`; };
};

É o mesmo padrão do Clock injetado do curador (lição 9): troque um global não determinístico por um seam injetado. Os testes injetam monotonicIdFactory('q') para poderem afirmar sobre q-1, q-2.

08

Confusões comuns

"Ele usa threads de verdade para bloquear." Não — o Node é single-thread. O "bloqueio" é uma Promise retornada que o chamador aguarda; um registry de resolvers (Map<id, pending>) deixa um callback da plataforma encerrá-la por id, e um setTimeout garante que nunca trava. É o equivalente fiel do threading.Event do Python num runtime assíncrono.
"Uma resposta inválida cancela a pergunta." Não — uma resposta inválida é rejeitada com err enquanto a pergunta segue pendente para uma correção. Só uma resposta válida (ou o timeout) remove a entrada.
"O coerceChoices garante o teto de 4." Não — ele só desembrulha dicts e descarta vazios. O teto é do schema (.max(MAX_CHOICES)), que falha fechado em uma lista >4.
"O 5º botão 'Outro' está no schema." Não — esse é um detalhe de UI/transporte. O modelo de dados portável só conhece de 1 a 4 opções predefinidas ou texto aberto.
CrençaRealidade
"bloqueio = thread parada"Promise + registry + timeout num event loop único
"inválida cancela"inválida ⇒ err e segue pendente
"coerceChoices corta para 4"quem corta é o schema; ele só limpa
"id aleatório serve"id monotônico injetável (replay + testes)
os quatro desfechos de resolve id desconhecido / já-encerrado⇒ err "Unknown or already-resolved"a estante não muda resposta inválida (kind / índice)⇒ err — mas a entrada FICA pendenteuma correção ainda pode chegar resposta válida⇒ settle(ok) · entries.delete(id)sai da estante double-resolve⇒ err — a 1ª já removeu a entradaa 1ª resposta é a que encerrou
Os quatro desfechos de resolve num quadro — só o verde (resposta válida) remove a entrada; só a laranja (inválida) a deixa pendente.
09

Recapitulando — em cinco slides

A peça

O T4 human-gate, portado

O ClarifyGateway é a pausa estruturada para uma decisão humana antes de uma etapa irreversível — um CLONE do clarify_gateway.py do Hermes, na forma do T4 human-gate (ADR-0005).

agente portão T4pausa · espera humano decide
1

O mecanismo

Promise + registry + timeout

Sem thread no Node: ask registra um pending no Map e devolve a promise; resolve encerra por id; o timeout descarta e resolve errnunca trava.

ask Map<id,pending>{settle,timer} resolve→ok timeout→err
2

Os dados

Escolha ou aberta, teto de 4

Uma união discriminada em kind: choice com 1–4 opções (MAX_CHOICES) ou open de texto livre. coerceChoices limpa dicts; o schema impõe o teto.

kind choice · 1–4 opções open · texto livre
3

A falha pensada

Deixa-pendente

Resposta inválida ⇒ err mas a entrada fica pendente para uma correção. Só uma resposta válida (ou o timeout) encerra. Validação cruzada vive no gateway, não no schema.

resolve pendente inválida ⇒ err, fica settle(ok)
4

A disciplina

Determinismo

Ids de uma fábrica injetável monotônica — nunca Math.random()/Date.now() — para replay reproduzível. Timeout padrão de 600s; timer.unref() deixa o processo sair.

idFactory()n += 1 clarify-1 clarify-2 clarify-3 sem random/Date ⇒ replay reproduzível
5
Slide 1 / 5 use
Recall ativo: sem olhar acima — o que sobra na estante de pendentes depois de um resolve com o kind errado? E depois de um resolve válido?
10

Verifique seu entendimento

Quiz cumulativo — 4 perguntas

Responda cada uma; o placar acompanha embaixo.

1. O resolve é chamado com uma resposta de texto aberto para uma pergunta de escolha. O que acontece?
Correto: c. O validateResponse rejeita o kind divergente com err, mas só uma resposta válida encerra a entrada — uma inválida fica pendente para um re-prompt. O teste confirma que pending() ainda lista o id depois.
2. Uma pergunta de clarify chega com 5 opções. Quando ela é rejeitada?
Correto: b. Cortar é trabalho do schema, não do coerceChoices. O ask valida a pergunta primeiro e retorna err de forma síncrona, sem registrar nada — falha fechado em vez de truncar entrada não confiável.
3. Por que o ClarifyGateway cunha ids via um monotonicIdFactory injetado em vez de um id aleatório?
Correto: d. A mesma disciplina do Clock injetado do curador: troque um global não determinístico por um seam injetado. Os testes injetam monotonicIdFactory('q') para afirmar sobre q-1, q-2.
4. O que coerceChoices(['', null, { name: 'n' }, { description: 'D' }]) retorna?
Correto: a. '' e null viram '' e são descartados; {name:'n'} não tem chave canônica, então colapsa para '' e cai; {description:'D'} desembrulha para 'D'. name/value ficam fora de propósito.
Acertos: 0/4
As cinco coisas para levar
  1. Bloquear no Node = uma Promise aguardada + um registry por id + um timeout — nunca uma thread parada.
  2. A pergunta é uma união discriminada em kind: choice (1–4 opções) ou open (texto livre).
  3. coerceChoices limpa dicts (ordem label→description→text→title; name/value fora); o schema impõe o teto de 4.
  4. A validação cruzada (kind igual, índice no intervalo) vive no resolve; resposta inválida ⇒ err e segue pendente.
  5. Ids monotônicos injetáveis (sem random/Date) + timer.unref() + timeout de 600s = determinismo sem travar.
A melhor coisa para ler a seguir
Lição 11 · Web profunda

O próximo subsistema portado do Hermes: como o agente busca e lê a web com proveniência e segurança.

Pergunta para fixar: se o ask validasse a pergunta dentro da Promise (assíncrono) em vez de antes dela, qual teste quebraria — e por quê? (Dica: "registers nothing".)