Duas ferramentas — buscar na web, extrair uma página — construídas como um kernel de despacho fino sobre um WebBackend injetado, com um seam Compressor opcional para resumo via LLM que economiza tokens. O kernel não resolve nada sozinho: valida o pedido, chama o backend injetado, re-valida o resultado não confiável com Zod e devolve um Result. O backend de produção é uma implementação fetch sem dependências. É um CLONE do web_tools.py do Hermes (1378 linhas).
Esta lição destila o subsistema web do @alembic/hermes ancorado linha a linha no código real do repositório. A proveniência do CLONE está em docs/hermes-complete-map.md §3.1. Nada aqui é inventado: cada afirmação é citável de arquivo ou de teste.
web não importa nenhum SDK e nenhum backend concreto — só dois ports: WebBackend e Compressor.ok([])) de erro (err) — e por que confundir os dois esconde uma queda real do provider.Compressor roda: só quando existe e o conteúdo cruza o piso DEFAULT_COMPRESS_MIN_LENGTH = 5000.createFetchBackend nunca abre um socket nos testes — o fetch é um campo injetável.Result das lições anteriores: ou ok(valor), ou err(erro) — nunca uma exceção atravessando a fronteira.A web é o mundo lá fora: caótica, não confiável, às vezes fora do ar. A sabedoria deste subsistema é tratar tudo que vem de fora como suspeito — e deixar o contrato (o que é uma "linha de busca válida") na mão de uma camada só, o kernel.
Imagine duas ferramentas que um agente usa: buscar ("o que existe sobre X?") e extrair ("me dá o texto desta página"). O ingênuo costura cada uma direto numa biblioteca de busca. Aqui, em vez disso, as duas falam com um port — um buraco em forma de tomada onde você pluga qualquer implementação: um fetch de verdade em produção, um dublê (fake) nos testes. O código que você vai ler não conhece nenhum provider específico.
Pense como… a recepção de um prédio. A recepcionista (o kernel) não conhece nenhum entregador pessoalmente; ela tem uma regra de quem pode subir (o contrato). Qualquer entregador (o backend) pode aparecer — o que importa é passar pela regra. Trocar de transportadora não muda a recepção. A analogia quebra num ponto: a recepcionista confia no crachá; aqui o kernel re-confere tudo, mesmo o que o entregador jurou já ter conferido.
O Hermes expõe web_search / web_extract sobre um registry de backends de provider intercambiáveis (Exa / Firecrawl / Parallel / Tavily / …), cada um implementando um par search()/extract(), mais uma passada opcional de LLM que comprime o conteúdo cru (process_content_with_llm, com piso DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION). Veja o cabeçalho de packages/hermes/src/web/types.ts.
Esta é a estrutura daquele subsistema portada para o estilo ports-and-injection do motor — não uma integração de API ao vivo. Os dois seams viram dois ports injetados. O cascade de auto-detecção de 7 backends, o split por capacidade, a descoberta de plugins, o log de debug-session, o filtro de SSRF/URL secreta e o registro de tool-schema OpenAI ficam de fora: são a camada de tool/segurança/transporte ainda não conectada. A saída não confiável do backend é validada com Zod na fronteira, no lugar dos normalizadores de resposta em Python; todo caminho falível é um Result<T, Error>, nunca lançado.
As duas fronteiras do subsistema: o backend fino mapeia de forma defensiva; o kernel valida com Zod e é o dono do contrato.
docs/hermes-complete-map.md §3.1): as 1378 linhas viram a estrutura portada — o cascade de 7 backends, SSRF e tool-schema ficam de fora, na camada ainda não conectada.A fonte se constrói em torno de dois seams — um registry de providers e uma passada opcional de compressão por LLM. Ambos viram ports injetados; este módulo importa nenhum SDK e nenhum backend concreto:
packages/hermes/src/web/types.tsexport interface WebBackend { /* ok([]) p/ nenhum hit · err p/ falha de provider/rede */ search(query: WebSearchQuery): Promise<Result<readonly WebSearchResult[], Error>>; extract(url: string): Promise<Result<WebExtractResult, Error>>; } export type Compressor = ( text: string, instruction: string, ) => Promise<Result<string, Error>>; // opcional; ausente ⇒ conteúdo cru
fetch real; nos testes, um dublê que devolve Results fixos. O mesmo kernel roda contra os dois sem mudar uma linha.Uma falha de rede é err; uma busca vazia é ok([]) — a diferença é preservada no tipo, exatamente como o data.web vazio da fonte. O Compressor espelha o seam ReviewProposer do loop de aprendizado: "uma chamada de modelo em produção, um fake nos testes".
description da fonte vira o idiomático snippet — um teste fixa snippet:'d' vindo de description:'d'.WebExtractResult guarda só o núcleo de sucesso { url, title, content }; uma extração falha é o err da linha, então nunca se disfarça de conteúdo vazio.O Compressor recebe text e instruction e devolve Promise<Result<string, Error>>. Se a chamada de modelo der erro (timeout do resumidor), o que webExtract deveria devolver — o conteúdo cru como plano B, ou propagar a falha?
err), fail-closed. Em maybeCompress, if (!compressed.ok) return compressed; — uma compressão que falhou vira err, não "devolve o cru por garantia". Devolver o cru em silêncio esconderia que o resumo que o chamador pediu não aconteceu. O teste "propagates a compressor error as err (fail closed)" fixa exatamente isto.O fluxo tem duas fronteiras. O backend fetch mapeia o JSON não confiável de forma defensiva (formas desconhecidas → strings vazias); depois o kernel re-valida cada linha com Zod, falhando a chamada inteira no primeiro erro:
const parsedQuery = webSearchQuerySchema.safeParse(query); if (!parsedQuery.success) return err(new Error(`Invalid web search query: …`)); const found = await deps.backend.search(clampQuery(parsedQuery.data)); // clamp [1,100] if (!found.ok) return found; const validated: WebSearchResult[] = []; for (const raw of found.value) { const parsed = webSearchResultSchema.safeParse(raw); // linha não confiável do backend if (!parsed.success) return err(new Error(`Invalid web search result: …`)); // 1 ruim ⇒ falha tudo validated.push(parsed.data); } return ok(validated);
url que não é URL (que o mapeador defensivo passa adiante feliz, como string) ainda é recusado pelo z.string().url() do webSearchResultSchema. Dois testes fixam isto: uma url faltando e uma url não-URL produzem ambos "Invalid web search result".export const webSearchResultSchema = z.object({ title: z.string(), // pode ser vazio url: z.string().url('result url must be a valid URL'), // ⇐ o portão snippet: z.string(), // o `description` da fonte, renomeado });
return err dentro do laço aborta na hora — não há "valida o resto e junta o que deu". Tudo passa, ou nada passa.maxResults é "clampado" para [1, 100] antes de o backend ver — o min(max(limit, 1), 100) da fonte:
const clampQuery = (query: WebSearchQuery): WebSearchQuery => { if (query.maxResults === undefined) return query; // ausente ⇒ passa adiante const clamped = Math.min(Math.max(query.maxResults, 1), 100); return { ...query, maxResults: clamped }; };
O teste "clamps maxResults into [1, 100]" manda 9999 e 1 e afirma que o backend vê exatamente [100, 1].
undefined) passa sem tocar.Três decisões que o tipo Result preserva: vazio ≠ erro, o clamp [1,100], e o piso de compressão.
Um conjunto de resultados vazio é ok([]), não um erro — "nenhum hit" é uma busca bem-sucedida, distinta de "o provider caiu" (err):
webSearchQuerySchema.safeParse({ query: 'gatos', maxResults: 9999 }) → sucesso (a query não é vazia, maxResults é inteiro positivo).clampQuery roda: min(max(9999, 1), 100) = 100. O backend recebe maxResults: 100, nunca 9999.ok([]) (nenhum gato hoje). found.ok é true, o laço de validação não tem o que iterar.webSearch devolve ok([]) — sucesso vazio. Quem chamou ramifica em result.ok e mostra "nenhum resultado", sem confundir com um erro.{ query: '' } (vazia)? Acompanhe o passo 1 — onde o fluxo para, e o backend chega a ser chamado? (Pista: o teste "rejects an empty query… before calling the backend" afirma called === false.)called === false.Para extrações, um Compressor injetado pode encolher páginas grandes para economizar tokens. Ele é pulado abaixo de um piso de tamanho (comprimir conteúdo pequeno desperdiça uma chamada de modelo) e é ausente por padrão:
const minLength = deps.compressMinLength ?? DEFAULT_COMPRESS_MIN_LENGTH; // 5000 if (compressor === undefined || result.content.length < minLength) { return ok(result); // sem compressor / pequeno demais ⇒ cru } const compressed = await compressor(result.content, instruction); if (!compressed.ok) return compressed; // falha do compressor ⇒ err (fail-closed) return ok({ ...result, content: compressed.value });
O piso padrão é DEFAULT_COMPRESS_MIN_LENGTH = 5000, mas deps.compressMinLength sobrescreve. O teste "honours a custom compressMinLength" passa compressMinLength: 4 e um conteúdo de 4 chars ('abcd') — agora o piso é cruzado e o compressor roda, devolvendo 'C'. Já o teste "skips the compressor below the size floor" passa um conteúdo 'tiny' com o piso padrão e afirma called === false e content === 'tiny': o compressor nem é chamado.
A instrução padrão entregue ao compressor é DEFAULT_COMPRESS_INSTRUCTION = "Summarize this web page, preserving all key facts, quotes, and code verbatim." — sobrescrevível por deps.compressInstruction. O teste "applies the compressor when content clears the size floor" afirma que a instrução vista tem comprimento > 0.
| Situação | Compressor roda? | Resultado |
|---|---|---|
| Sem compressor injetado | não | ok conteúdo cru |
| Conteúdo < piso (5000) | não | ok conteúdo cru |
| Compressor + conteúdo ≥ piso | sim | ok conteúdo comprimido |
| Compressor falha (timeout) | sim | err (fail-closed) |
createFetchBackend fala uma API JSON genérica sobre o fetch global do Node (Node 18+) — sem node-fetch, sem SDK, sem dependência nova. O endpoint e a chave são injetados (nunca hardcoded), e o próprio fetch é um campo de config injetável, com padrão no global. Então os testes passam um fetch falso e exercitam todo o mapeamento de pedido/resposta sem uma chamada de rede (espelhando a injeção de idFactory em clarify). Uma exceção de rede, um status não-2xx e um JSON impossível de parsear viram cada um err — provado por três testes de transporte. E o rename é real: o description da fonte vira o idiomático snippet em exatamente um lugar, com um teste afirmando snippet:'d' a partir de description:'d'.
const doFetch = config.fetch ?? (globalThis.fetch as unknown as FetchLike); // padrão = global const postJson = async (doFetch, url, headers, body) => { const responded = await tryCatchAsync(() => doFetch(url, { method: 'POST', headers, body })); if (!responded.ok) return responded; // throw de rede ⇒ err const response = responded.value; if (!response.ok) // status não-2xx ⇒ err return err(new Error(`web backend HTTP ${response.status} …`)); return tryCatchAsync(() => response.json()); // JSON inválido ⇒ err };
results ou web, puxa title/url/snippet (com fallback para description) e nunca lança — formas desconhecidas colapsam para string vazia. A validação final é do kernel.description → snippet e devolver string vazia para um campo ausente não é validação — é normalização defensiva. A garantia ("a url é mesmo uma URL?") só acontece no kernel. Não confunda "mapeou sem quebrar" com "validou".tryCatchAsync embrulha cada caminho; três testes (throw de rede, HTTP 503, JSON impossível) fixam que nenhum atravessa a fronteira como exceção.// sem Date.now() / Math.random() (a plan VM do motor proíbe ambos); // o pedido não carrega nonce nem timestamp.
Por isso o backend roda dentro de um alembic.plan.ts sem disparar o detector de não-determinismo: a mesma entrada produz o mesmo pedido, sempre.
Date.now() e Math.random(); o pedido não carrega nonce nem timestamp, então é reproduzível.O backend devolveu uma linha. O kernel a re-valida. Escolha a linha e veja o veredito — e por que ele falha ou passa:
{ title: "Exemplo",
url: "https://example.com",
snippet: "um resultado" }
Passa. A linha tem os três campos e a url é uma URL válida — webSearchResultSchema.safeParse sucede e a linha entra em validated.
Invalid web search result — e uma linha ruim falha a chamada inteira. O kernel não conserta, não descarta em silêncio: ele fecha a porta.Alterne entre a explicação leiga e a precisa. Use a aba "Técnico" quando quiser os nomes reais; a "Simples" quando quiser a intuição.
webSearch faz webSearchQuerySchema.safeParse → clampQuery [1,100] → backend.search → laço de webSearchResultSchema.safeParse por linha (1 falha ⇒ err). webExtract faz backend.extract → webExtractResultSchema.safeParse → maybeCompress (gated por compressMinLength ?? DEFAULT_COMPRESS_MIN_LENGTH). O backend de produção é createFetchBackend sobre globalThis.fetch com tryCatchAsync liftando throw/não-2xx/JSON-inválido para err. Nenhum SDK; fetch injetável.Vire cada cartão (clique, ou Enter/Espaço) e tente responder antes de ver o verso. É prática de recuperação — vale mais que reler.
WebBackend (par search/extract, o registry de providers) e Compressor (opcional, a passada de LLM). O kernel importa nenhum SDK concreto.url não-URL passa pelo mapeador mas é recusada pelo .url() do Zod.ok([]) (sucesso vazio); o provider caiu é err. Conflatá-los esconderia uma queda real atrás de "sem resultados".DEFAULT_COMPRESS_MIN_LENGTH (5000). Sem ele, ou abaixo do piso, devolve o conteúdo cru.min(max(9999, 1), 100) = 100. O clamp roda antes de o backend ver; o teste afirma que o backend vê [100, 1].fetch é um campo de config injetável (padrão = global). Os testes passam um fetch falso e exercitam o mapeamento sem rede.createFetchBackend, e mesmo ele recebe um fetch injetável para que os testes nunca abram um socket.1 ruim ⇒ falha tudo. Descartar em silêncio mascararia um backend mal-comportado; falhar a chamada inteira torna o problema visível e força o conserto na fonte certa.WebExtractResult guarda só o núcleo de sucesso { url, title, content } — o error por-URL da fonte vira o err da própria linha, não um campo inline. Por que isso é melhor do que um campo error?: string dentro de uma linha "de sucesso"? (Pista: uma extração que falhou nunca pode se passar por "conteúdo vazio".)
Responda as três; o placar abaixo conta seus acertos.
url é a string "not-a-url". O que webSearch retorna?url não-URL falha o z.string().url(), e uma linha ruim falha a chamada inteira (nunca lança).webExtract recebe uma página de 3000 chars e um Compressor (piso padrão 5000). O que acontece?maybeCompress devolve a linha inalterada quando não há compressor ou o conteúdo está abaixo de compressMinLength (padrão DEFAULT_COMPRESS_MIN_LENGTH = 5000). Comprimir conteúdo minúsculo desperdiça uma chamada de modelo.web importa nenhum SDK e nenhum backend concreto — só os ports WebBackend e Compressor.ok([]) (vazio) ≠ err (queda do provider) — e uma linha inválida falha a chamada inteira, fail-closed.Compressor roda só se existe e o conteúdo cruza o piso 5000; uma falha dele propaga como err.createFetchBackend é zero-dependência e injeta o fetch — os testes provam transporte e mapeamento sem abrir um socket.