Saída estruturada
Validar é só metade
"Saída estruturada" tem três passos: validar → reparar → re-perguntar. O Alembic faz o primeiro com rigor; os outros dois não existem como laço automático. Saber dessa fronteira é o valor.
1No nosso pedido atravessando o harness, depois que o adapter responde, esta lição mostra como o Alembic torna impossível um sucesso malformado — Zod nas duas pontas — e onde ele honestamente para: não há laço que conserte e re-pergunte. Você vai saber o que está garantido e o que ainda é roadmap.
packages/adapters/src/openai-compatible.ts
Esta lição destila o princípio "structured-output failures + schema validation + repair" e o "function-calling reliability + tool contracts + idempotency" do mapa, mostrando linha a linha onde o Alembic os cumpre e onde os deixa em aberto. Referência didática paralela: o currículo PT-BR da WalkingLabs.
Um modelo de linguagem devolve texto. Texto pode estar errado, truncado, embrulhado em prosa ou em cercas de markdown. Saída estruturada é a disciplina de transformar esse texto frouxo num objeto tipado e validado antes que qualquer outra parte do sistema confie nele. No harness do Alembic isso acontece numa fronteira (boundary) específica, com Zod, e — esta é a parte honesta — com um limite claro do que ele faz e do que não faz.
PARSE_ERROR é não-retentável.looseJsonParse realmente faz (fatia + descarta), e por que isso não é "reparar".Pense no caminho do nosso exemplo recorrente: um pedido entra, o tier routing escolhe o modelo, o adapter chama o modelo e devolve um Result. A lição passada (0003) cuidou de tudo que está abaixo da cintura (KV cache, quantização — delegado). Esta lição cuida do instante seguinte: o que chega de volta é confiável?
Pense como… a inspeção de qualidade na entrada de uma fábrica. Toda peça que chega é medida contra um gabarito (o schema). Peça fora do gabarito não entra na linha — vira refugo registrado, não uma peça "quase boa" que segue adiante e quebra a montagem três etapas depois. Onde a analogia quebra: uma fábrica de verdade às vezes retrabalha a peça (lixa, ajusta) e a reaproveita. O Alembic, hoje, não retrabalha a saída do modelo — ele a descarta e, se for o caso, refaz a chamada inteira. Esse "não retrabalhar" é exatamente o GAP que esta lição ensina.
A validação no Alembic é pervasiva e baseada em Zod. Toda entrada de adapter passa por modelRunInputSchema.safeParse na fronteira do guard (vira um CLIENT_ERROR tipado, nunca um throw). Blocos de uso (tokens) do provedor são coagidos via safeParse e devolvem undefined — nunca fabricam contagem — quando a forma vem errada. Votos de council validam contra councilVoteSchema; abaixo do quórum vira NO_GO. O pack de 8 camadas e cada gate validam via Zod e devolvem Result.
O status no HARNESS-MAP é owned para validação de schema — é o estilo da casa (@alembic/contracts é dono dos schemas). E é gap para o laço de auto-reparo/re-pergunta: saída ruim → falha tipada → no máximo refaz a chamada inteira (se retryable), nunca "re-pergunta com o erro de parse anexado".
O boundary tem quatro estágios. A entrada é construída tipada; a resposta HTTP é validada (safeParse); o sucesso é reconstruído e revalidado (parse). Repare na seta vermelha tracejada que não existe: o caminho de volta "conserta e re-pergunta". Esse vão é o GAP.
ModelRunInput tipado, é despachado, a resposta é validada com safeParse e o sucesso é revalidado com parse. Em verde: o caminho feliz. Em vermelho cheio: a falha tipada PARSE_ERROR. Em vermelho tracejado: o laço de reparo que o Alembic não tem.A sutileza que separa um harness sério de um script é: o Alembic valida duas vezes, e com severidades diferentes de propósito.
A resposta crua do gateway é dado externo não confiável. Ela passa por openAiChatResponseSchema.safeParse. Se a forma vier errada, vira um err(PARSE_ERROR) tipado. Nunca explode. É esperado que um servidor possa devolver lixo — então a falha é de dados, recuperável pela lógica de cima.
Quando já se sabe que deu certo, o Alembic reconstrói o objeto de sucesso e o valida com modelRunSuccessSchema.parse — a versão que lança. Por quê? Porque um sucesso malformado aqui seria um bug do próprio Alembic, não do modelo. Falhar alto expõe o bug em vez de propagá-lo.
safeParse devolve {success, data|error} e nunca lança — é para dado que pode vir errado (a resposta da rede). parse lança em forma inválida — é para invariantes que nunca deveriam falhar (o objeto que nós mesmos montamos). Usar a ferramenta certa em cada ponto é a engenharia.O gateway respondeu 200 OK, mas o corpo é {"choices": []} (uma lista de escolhas vazia). O que o Alembic faz?
Falha tipada, não sucesso. O schema exige choices: z.array(...).min(1) — uma lista vazia não passa no safeParse, então vira err(PARSE_ERROR, "malformed response…"). E mesmo que passasse, extractText não acharia conteúdo e devolveria outro PARSE_ERROR ("response had no assistant content"). Dois guardas independentes garantem que um "200 vazio" nunca vira um sucesso silencioso de texto vazio que envenena o resto do run.
No caminho pedido → tier → adapter → gates, os gates (Proof, verifier) confiam que um Result.ok === true carrega um objeto bem-formado. Essa confiança só é legítima porque a fronteira do adapter já provou a forma. Se a validação fosse frouxa, o erro vazaria para frente e estouraria num lugar distante e difícil de depurar — exatamente o modo de falha "malformed JSON" que o HARNESS-MAP lista como produção-realista (princípio #22).
ModelAdapter + Result<T,Error> que nunca lança. Esta lição é o que acontece dentro dessa cintura quando a resposta chega: a validação que faz o Result ser honesto.Aqui é onde a honestidade do curso ganha valor. O livro-texto de "structured output" tem três passos: validar → reparar → re-perguntar. O Alembic faz o primeiro com rigor. Os outros dois não existem como laço automático. Vamos ver exatamente o que existe e o que não existe.
err(PARSE_ERROR), que é não-retentável → no máximo a chamada inteira é refeita (se o erro for retryable por outro motivo, como rede). Nunca "re-pergunte ao modelo com o erro de parse anexado para ele se corrigir".Há um lugar no repo que tenta recuperar JSON de uma resposta embrulhada: looseJsonParse, no funil de destilação. Mas leia o que ele realmente faz:
// Tenta um parse direto; depois a primeira fatia balanceada {...}. export const looseJsonParse = (text: string): unknown => { const tryParse = (s: string): unknown | undefined => { try { return JSON.parse(s) as unknown; } catch { return undefined; } }; const direct = tryParse(text.trim()); if (direct !== undefined) return direct; const start = text.indexOf('{'); const end = text.lastIndexOf('}'); if (start >= 0 && end > start) return tryParse(text.slice(start, end + 1)); return undefined; // nada recuperável → o sinal é DESCARTADO acima };
É exatamente fatia + descarta: tenta o texto inteiro; se falhar, recorta do primeiro { ao último } e tenta de novo; se ainda falhar, devolve undefined. E quem chama (signalFromResult) trata undefined descartando o sinal — não re-perguntando, não consertando vírgula, não fechando chave.
JSON.parse (direto, depois fatiado) e — se as duas falham — o descarte. Em nenhum ramo há conserto de sintaxe ou re-pergunta ao modelo.| O livro-texto faz | O Alembic faz |
|---|---|
| Valida contra schema | Sim — Zod nas duas pontas (owned) |
| Repara JSON (fecha chaves, conserta aspas) | Não — só fatia {…} e tenta um JSON.parse |
| Re-pergunta ao modelo com o erro anexado | Não existe — PARSE_ERROR não-retentável (gap) |
| Cadeia de fallback de modelo na falha de parse | Não — falha tipada; quem chama decide descartar |
Um laço de re-pergunta parece grátis, mas: (1) gasta tokens a cada tentativa — sem um teto, vira um sorvedouro de custo; (2) é não-determinístico — a "correção" do modelo é mais uma amostra estocástica, então o resultado deixa de ser reproduzível, o que colide com a regra de determinismo da VM do Alembic (lição 0005); (3) mascara regressões — se um modelo passou a devolver lixo, falhar limpo te avisa; remendar esconde. O Alembic prefere a falha honesta + a decisão explícita de quem chama (descartar o sinal, escalar o tier) a um remendo opaco. O gap é real, mas a postura é coerente com "shipping como infraestrutura confiável" (princípio #23).
Um repair seria aditivo: na falha de safeParse, montar um novo ModelRunInput com o texto original + o parsed.error.message + a instrução "devolva apenas JSON válido", com um teto de tentativas (ex.: 1) e contabilizado pelo BudgetGuard. Caberia atrás de uma flag, sem mudar o contrato never-throws.
Ferramentas (tools) são como um agente age no mundo. Dois riscos clássicos: o modelo alucina uma chamada de ferramenta, ou duplica uma. A defesa do livro é "contratos tipados + idempotência". O Alembic faz algo mais radical na sua superfície mais exposta — o servidor MCP: ele simplesmente não tem ferramentas de escrita.
/**
* A superfície de ferramentas MCP é estritamente READ-ONLY.
* Não há, de propósito, nenhuma tool `start`/`fanout` aqui: um cliente MCP
* pode INSPECIONAR um run (status, swimlanes, o ledger de parked) mas nunca
* pode causar execução autônoma por esta superfície. Essa assimetria É a
* propriedade de segurança — o transporte menos confiável (um host MCP
* arbitrário) recebe a MENOR autoridade. Cada handler valida sua entrada
* com Zod e devolve um ToolResult; nenhum lança.
*/
As três ferramentas expostas são harness_status, harness_events e harness_lane — todas leitura pura. Cada uma valida seus argumentos via Zod (invokeTool faz safeParse antes de despachar), então uma chamada MCP malformada vira um erro tipado, não uma exceção.
isError estruturado, jamais uma exceção.context_pack, artifact_read) também é leitura pura, e o artifact_read ainda confina a leitura sob o diretório do run com um guard anti-escape de caminho.TaskSpec tipado por dentro + superfície MCP read-only por fora. O modelo de confiança é explícito: quanto menos confiável o transporte, menos autoridade ele recebe.Idempotente = repetir a operação dá o mesmo resultado, sem efeito duplicado. O livro-texto coloca isso por chamada de ferramenta (uma chave de idempotência por requisição). O Alembic resolve num lugar diferente e, para o modelo dele, melhor: no nível do run e do journal.
O id do run é o hash do seu spec imutável. Re-submeter um run idêntico cai no mesmo diretório on-disk — não bifurca uma árvore nova. "Filesystem como verdade."
packages/swarm/src/ids.ts:30export const runIdFor = (spec: unknown): string => `run-${shortHash(spec)}`;
O worker escreve num arquivo in-progress, depois faz rename para o nome terminal. O orquestrador só observa o nome terminal — então nunca vê um relatório meio-escrito. O rename é o commit.
await writeFile(tmp, renderReport(report)); await rename(tmp, final); // atômico = commit
Junte as duas peças e você tem replay seguro: re-rodar um run idêntico reusa o diretório endereçado por conteúdo + o journal append-only + o cache de resultado. A segurança de repetição vem da arquitetura (conteúdo-endereçado + rename atômico + journal), não de tokens de idempotência por ferramenta.
runIdFor real: três submissões idênticas colapsam num único diretório (resume funciona). À direita, o contrafactual com id aleatório: cada submissão bifurca, e não há o que reusar.rename atômico) conta como concluído.t1 e escreve t1.report.md (in-progress)..report.md parcial — mas o orquestrador não vigia esse nome.--resume, collectReport procura só por t1.complete.md/t1.failed.md (nomes terminais). Não acha → trata t1 como não feita.rename para o nome terminal. Agora sim o orquestrador a vê como concluída. Nenhum estado meio-feito foi confundido com sucesso.randomUUID) em vez de hash do spec. O que o --resume reusaria? Nada — cada submissão criaria uma árvore nova. É o runIdFor endereçado por conteúdo que torna o resume possível.--resume, contando com o cache + o journal append-only, não com tokens por ferramenta. Isso é adequado ao modelo de DAG limitado; importaria se ferramentas de escrita fossem expostas a um chamador não confiável (mas, como vimos, elas não são).Aqui está o coração da lição: o caminho de sucesso do transporte OpenAI-compatível. Repare nas duas validações (a safeParse da resposta na linha 158, o parse do sucesso na linha 179) e em como cada falha vira um Result, nunca um throw.
// 1) A resposta crua do gateway é dado externo -> safeParse (nunca lança). const parsed = openAiChatResponseSchema.safeParse(response.value.body); if (!parsed.success) { return makeFailure( { ...ctx, durationMs, raw: response.value.body }, makeError(ErrorCode.PARSE_ERROR, `malformed response: ${parsed.error.message}`), ); } const text = extractText(parsed.data); if (text === undefined) { // 200, mas sem conteúdo de assistant return makeFailure( { ...ctx, durationMs, raw: response.value.body }, makeError(ErrorCode.PARSE_ERROR, 'response had no assistant content'), ); } // 2) O sucesso é NOSSO objeto -> parse (LANÇA se malformado = bug do Alembic). return modelRunSuccessSchema.parse({ ok: true, adapterId: config.adapterId, durationMs: Math.max(0, durationMs), modelId: input.modelId, text, raw: response.value.body, ...(usage ? { usage } : {}), ...(costUsd === undefined ? {} : { costUsd }), }) satisfies ModelRunResult;
Em uma frase: o que vem de fora é checado com luvas (safeParse), o que montamos nós mesmos é checado com rigor (parse). As duas falhas de dados viram PARSE_ERROR tipado; o sucesso só é devolvido se sobreviver à revalidação.
extractText varre parsed.choices e devolve o primeiro content string não-vazio; uma mensagem presente-mas-vazia é tratada como completion vazia válida, mas undefined (sem nenhum content string) é falha. accountFor(input.modelId, …) precifica o uso pelo registry e devolve undefined (não 0) para modelo desconhecido — por isso o spread condicional costUsd === undefined ? {} : {costUsd}: nunca se inventa um custo. O satisfies ModelRunResult dá a checagem de tipo em build-time sem alargar o tipo do retorno.
No repo: packages/adapters/src/openai-compatible.ts (o transporte), packages/harness/src/mcp.ts (a superfície read-only), packages/swarm/src/{ids,worker}.ts (idempotência por conteúdo + rename), packages/harness/src/funnel.ts linha 204 (looseJsonParse). Procure por safeParse com grep -rn "safeParse" packages/ para ver o quão pervasiva é a validação de fronteira.
Os seis pontos que fixam a lição. Use as setas ← → ou os botões.
Saída estruturada
"Saída estruturada" tem três passos: validar → reparar → re-perguntar. O Alembic faz o primeiro com rigor; os outros dois não existem como laço automático. Saber dessa fronteira é o valor.
1Dois lados
A resposta do modelo (dado externo) usa safeParse → falha tipada. O sucesso (nosso objeto) usa parse → lança, porque um sucesso malformado seria um bug nosso. Um sucesso malformado é impossível.
2O gap
Saída inválida vira err(PARSE_ERROR) — não há re-pergunta com o erro anexado. O único "remendo", looseJsonParse, só fatia {…} e tenta um parse; falhou, descarta. Fatia + descarta, nunca repara.
Ferramentas
A superfície MCP não tem tool de escrita. O transporte menos confiável recebe a menor autoridade. Sem tool de escrita exposta, não há chamada para alucinar ou duplicar: idempotência por construção.
4Idempotência
runId é o hash do spec (mesmo input → mesmo diretório); o worker faz rename atômico (o rename é o commit). Replay seguro vem da arquitetura, não de tokens de idempotência por chamada.
5A moldura
Estas escolhas mostram um harness que prefere falhar limpo a remendar. O laço de repair ausente é o mesmo tipo de "loop aberto" do sinal-do-usuário (lição 0008): a fronteira entre confiável e auto-evolutivo.
6Clique para virar. Tente responder antes de ver o verso — é prática de recuperação, não leitura passiva.
safeParse e quando usar parse?safeParse (nunca lança) para dado externo que pode vir errado — a resposta do modelo. parse (lança) para um invariante nosso que nunca deveria falhar — o objeto de sucesso que montamos.PARSE_ERROR é não-retentável: saída inválida é descartada ou a chamada é refeita inteira, nunca "re-perguntada com o erro anexado".looseJsonParse realmente faz?{ ao último } e tenta um JSON.parse; se ainda falhar, devolve undefined e quem chama descarta o sinal. Não conserta nada.runId = hash do spec (mesmo input → mesmo diretório) + rename atômico (o rename é o commit) + journal append-only.modelRunSuccessSchema.parse antes de retornar. Se a forma não bate, parse lança — então nenhum Result.ok sai com forma inválida.const data = JSON.parse(resp.body); // se body for lixo -> THROW lá longe // se faltar campo -> undefined viaja useIt(data.text); // estoura aqui, sem contexto
O erro aparece longe da causa, sem o corpo cru para depurar.
const parsed = schema.safeParse(resp.body); if (!parsed.success) return makeFailure( { raw: resp.body }, // corpo cru anexado makeError(PARSE_ERROR, parsed.error.message));
O erro nasce na fronteira, tipado, com o corpo cru no raw.
| Dimensão | Laço de repair (livro-texto) | Descartar/refazer (Alembic) |
|---|---|---|
| Custo de tokens | Cresce a cada tentativa de re-pergunta | Zero extra — não re-pergunta |
| Determinismo | Quebra (a "correção" é outra amostra) | Preservado — colável ao cache/VM |
| Visibilidade de regressão | Mascara (remenda em silêncio) | Alta — falha limpa avisa |
| Recuperação de saída salvável | Maior (conserta quase-JSON) | Menor (só o que looseJsonParse fatia) |
| Status no HARNESS-MAP | — | gap consciente (roadmap) |
Escolha um payload que "o modelo devolveu" e veja o boundary reagir estágio a estágio. Depois ligue o modo repair (hipotético) para ver o que mudaria — e perceber que, no Alembic real, esse botão está sempre em off.
looseJsonParse fatia o {…}. "Vírgula sobrando" é o caso revelador: o modo descartar perde o sinal; o modo repair hipotético o recuperaria — e é justamente esse modo que o Alembic não tem.grep -rn "PARSE_ERROR" packages/ — quantos pontos de fronteira devolvem essa falha tipada? Pergunta de acompanhamento para levar à próxima lição: se a saída inválida só é descartada, o que impede um agente de ficar preso tentando para sempre? A resposta — budgets de loop, terminação por DAG e o T4 park — é a lição 0005 · Loop do agente & guardrails.JSON.parse estourar pelo menos uma vez na vida.Result da lição 0002).safeParse (luva). Dado nosso → parse (rigor). Se você lembrar só disso, já aplica o padrão certo em 90% dos boundaries.safeParse (nunca lança) → falha de dados tipada.parse (lança) → um sucesso malformado é bug nosso, não do modelo.PARSE_ERROR tipado, com o corpo cru anexado em raw.PARSE_ERROR é não-retentável: não há re-pergunta com o erro anexado.looseJsonParse, fatia + descarta — não repara.Três perguntas. O placar abaixo soma seus acertos. Clique uma opção e leia a explicação — inclusive das erradas.
200 OK com um corpo que falha no safeParse. O que o Alembic devolve?safeParse → na falha, makeFailure(…, makeError(PARSE_ERROR, …)) com raw: response.value.body. (a) erra: não se inventa sucesso. (c) erra: o contrato é never-throws. (d) erra: é justamente o laço de repair que não existe (o gap).
parse (que lança) e não safeParse?Os arquivos reais desta lição: packages/adapters/src/openai-compatible.ts, packages/harness/src/mcp.ts, packages/swarm/src/{ids,worker}.ts, packages/harness/src/funnel.ts (looseJsonParse).