Quando o agente precisa de uma decisão humana antes de prosseguir, ele levanta uma pausa estruturada: uma pergunta (múltipla escolha ou aberta), e bloqueia na resposta. Em termos Alembic, esta é a superfície do portão humano T4 (ADR-0005). O Python bloqueia uma thread num threading.Event; o Node não tem thread bloqueante, então o equivalente fiel é uma promise + um registro de resolvers + um timeout. Um CLONE do clarify_tool.py + clarify_gateway.py do Hermes.
// 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 SINCRONAMENTE const id = this.mintId(); return new Promise((resolvePromise) => { const timer = setTimeout(() => { this.entries.delete(id); // descarta a entry… 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 }); });
timer.unref() deixa o processo Node sair mesmo enquanto um clarify está pendente — um prompt humano travado não mantém o runtime vivo para sempre.Uma pergunta é uma união discriminada em kind. Uma pergunta choice carrega 1..MAX_CHOICES opções; MAX_CHOICES = 4 é o teto de dados (a 5ª opção "Other" da UI é uma preocupação de apresentação, não modelada aqui):
// 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, …) }), ]);
Um helper sutil de robustez vem junto: coerceChoices, um clone do _flatten_choice da fonte. LLMs às vezes emitem choices em forma de dict ([{description:'…'}]) em vez de strings puras; isto as desembrulha por chaves canônicas de label em ordem de prioridade:
// packages/hermes/src/clarify/types.ts:104-121 — flattenChoice const CHOICE_LABEL_KEYS = ['label', 'description', 'text', 'title'] as const; // name/value EXCLUÍDAS // string ⇒ trimada; dict ⇒ primeira chave canônica não-vazia; senão ⇒ '' (descartada)
name/value são deliberadamente excluídas — carregam valores de enum/identificadores brutos, não labels humanos, e um label-lixo é pior que nenhuma choice (o dict colapsa para '' e é descartado). Note que coerceChoices não limita a 4 — limitar é o trabalho do schema, então uma lista longa demais falha fechado na validação em vez de ser silenciosamente truncada.
O Zod consegue checar a forma de uma resposta, mas não se ela serve à pergunta viva. resolve impõe os invariantes cruzados — match de kind e índice-na-faixa — que o schema não consegue:
// 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 …`)); }
Quando resolve recebe uma resposta inválida (kind errado, índice fora da faixa), retorna err mas deixa a entry pendente — para que uma resposta corrigida ainda possa chegar e liquidar a mesma promise. Só uma resposta válida liquida (e remove) a entry. O teste prova: um resolve com kind incompatível falha mas pending() ainda lista o id; um resolve correto subsequente então o liquida. Em contraste, um id desconhecido ou já-liquidado é um err simples ("Unknown or already-resolved"), e um double-resolve falha porque o primeiro já removeu a entry.
Math.random()Ids vêm de uma factory injetável, com padrão de contador monotônico — nunca Math.random()/Date.now(), que a plan VM do motor rejeita e que quebrariam replay:
// packages/hermes/src/clarify/gateway.ts:176-182 export const monotonicIdFactory = (prefix = 'clarify'): (() => ClarifyId) => { let n = 0; return () => { n += 1; return `${prefix}-${n}`; }; };
O timeout padrão é DEFAULT_CLARIFY_TIMEOUT_MS = 600_000 (10 minutos), espelhando os 600s da fonte. Os testes o dirigem com fake timers do vitest, avançando além do prazo para provar que a entry é descartada e a promise resolve para err — sem leak, sem trava.
resolve é chamado com uma resposta de texto aberto para uma pergunta de choice. O que acontece?validateResponse rejeita um kind incompatível com err, mas só uma resposta válida liquida a entry — uma inválida a deixa pendente para um re-prompt. O teste confirma que pending() ainda lista o id depois.coerceChoices. ask valida a pergunta primeiro e retorna err sincronamente, registrando nada — falhando fechado em vez de truncar silenciosamente entrada não confiável.ClarifyGateway cunha ids via um monotonicIdFactory injetado em vez de um id aleatório?Clock injetado do curador: substituir um global não-determinístico por uma costura injetada. Testes injetam monotonicIdFactory('q') para poderem afirmar sobre q-1, q-2.Promise retornada que quem chamou aguarda; um registro de resolvers (Map<id, pending>) deixa um callback de plataforma liquidá-la por id, e um setTimeout garante que nunca trave. É o equivalente fiel do threading.Event do Python num runtime async.err enquanto a pergunta fica pendente para uma resposta corrigida. Só uma resposta válida (ou o timeout) remove a entry.