Curso / Lição 10  ·  EN
Lição 10 · Mergulho profundo · subsistema 4 de 7

ClarifyGateway — o portão humano T4

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.

O mecanismo: ask registra, resolve liquida

ask(question) validar · cunhar id · armar timer entries: Map<id, pending> { question, settle, timer } registrar resolve(id, resp) callback da plataforma validar vs pergunta → settle(ok) timeout dispara deletar entry · resolve(err)
// 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 });
});
Dois detalhes que importam. (1) Uma pergunta inválida falha fechado sincronamente — antes de qualquer entry 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 enquanto um clarify está pendente — um prompt humano travado não mantém o runtime vivo para sempre.

O contrato de dados: choice ou open, limitado a 4

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.

Validação cruzada: a resposta tem que servir à pergunta

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 …`));
}
A regra "deixe pendente" — um modo de falha pensado

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.

Determinismo: ids monotônicos, sem 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.

1. resolve é chamado com uma resposta de texto aberto para uma pergunta de choice. O que acontece?
Correto: c. 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.
2. Uma pergunta clarify chega com 5 choices. Quando é rejeitada?
Correto: b. Limitar é trabalho do schema, não do coerceChoices. ask valida a pergunta primeiro e retorna err sincronamente, registrando nada — falhando fechado em vez de truncar silenciosamente 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. Mesma disciplina do 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.

Confusões comuns

"Ele usa threads reais para bloquear." Não — o Node é single-threaded. O "bloqueio" é uma 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.
"Uma resposta inválida cancela a pergunta." Não — uma resposta inválida é rejeitada com err enquanto a pergunta fica pendente para uma resposta corrigida. Só uma resposta válida (ou o timeout) remove a entry.