ClarifyGateway, o T4 human-gateQuando 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.
Promise em JavaScript — sabe que ela resolve mais tarde.Map (uma tabela chave → valor).Result<T, Error> e determinismo. Tudo o que importa aqui é re-explicado.ask → pending → resolve/timeout e dizer onde cada estado vive.Math.random()) e o timer.unref().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.
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.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.
threading.Event do Python e a Promise+registry do Node são o mesmo contrato: bloqueie até a resposta, com um limite de tempo.Promise que o chamador aguarda — e ter um jeito (id + timeout) de sempre encerrá-la.resolve) — ou quando o alarme do fechamento (timeout) a remove.ask registra, resolve encerraTrê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.
O threading.Event do Python vira promise + registry de resolvers + timeout no Node. ask registra; resolve encerra; o timeout nunca deixa travar.
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 }); });
(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.
Uma pergunta chega malformada (por exemplo, com 5 opções). Em que momento ela é rejeitada — e o que sobra na estante de pendentes?
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.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: uma pergunta válida registra a entrada; uma inválida falha fechado antes de registrar — por isso o teste "registers nothing".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.
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, …) }), ]);
kind, dois formatos. O teto de 4 é uma regra de dados; a UI acrescenta o "Outro" por conta própria.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.coerceChoicesModelos (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.
label → description → text → title. A primeira chave com texto não vazio vence; name/value ficam de fora.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 [].
coerceChoices 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.{label:'win', text:'lose'}?label — é a primeira em CHOICE_LABEL_KEYS. A ordem é label → description → text → title; a primeira não vazia ganha.{name: 'n'} depois do coerceChoices?'' e é descartado. name/value são excluídos por carregarem identificadores crus, não rótulos..max(MAX_CHOICES)), não o coerceChoices. Lista >4 falha fechado na validação.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.
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.
choiceQ(['a', 'b']) — 2 opções, índices válidos 0 e 1.resolve('q-1', { kind: 'choice', index: 2 }). O schema aprova: index é um inteiro ≥ 0. Forma boa.2 >= choices.length (2) ⇒ verdadeiro. Retorna err("out of range").pending() ainda lista ['q-1']. Uma resposta corrigida (índice 0 ou 1) ainda pode chegar.index: 3. Aprova no schema? Passa no gateway? (Sim no schema; não no gateway — 3 >= 3. Fica pendente.)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.
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']
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.
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.
resolve válido é o que encerra a promise e remove a entrada; o 2º não encontra mais nada e retorna err.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.
Clock injetado do curador.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.
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.
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.err enquanto a pergunta segue pendente para uma correção. Só uma resposta válida (ou o timeout) remove a entrada.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.| Crença | Realidade |
|---|---|
| "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) |
resolve num quadro — só o verde (resposta válida) remove a entrada; só a laranja (inválida) a deixa pendente.resolve com o kind errado? E depois de um resolve válido?Responda cada uma; o placar acompanha embaixo.
resolve é chamado com uma resposta de texto aberto para uma pergunta de escolha. O que acontece?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.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.ClarifyGateway cunha ids via um monotonicIdFactory injetado em vez de um id aleatório?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.coerceChoices(['', null, { name: 'n' }, { description: 'D' }]) retorna?'' 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.Promise aguardada + um registry por id + um timeout — nunca uma thread parada.kind: choice (1–4 opções) ou open (texto livre).coerceChoices limpa dicts (ordem label→description→text→title; name/value fora); o schema impõe o teto de 4.resolve; resposta inválida ⇒ err e segue pendente.timer.unref() + timeout de 600s = determinismo sem travar.O próximo subsistema portado do Hermes: como o agente busca e lê a web com proveniência e segurança.
ask validasse a pergunta dentro da Promise (assíncrono) em vez de antes dela, qual teste quebraria — e por quê? (Dica: "registers nothing".)