webSearch / webExtract — portas sobre fetch
Duas ferramentas — buscar na web e extrair uma página — construídas como um kernel de despacho fino sobre uma porta WebBackend injetada, com uma costura opcional Compressor para sumarização 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 impl fetch sem nenhuma dependência. É um CLONE do web_tools.py do Hermes (1378 LOC).
Esta lição destila esses quatro arquivos, lidos literalmente. Cada número e cada linha de código tem fonte (rodapé). Por que importa pra missão: é o padrão que deixa o Alembic buscar e ler a web sem acoplar a nenhum provedor — e sem abrir um socket nos testes.
- Explicar por que o kernel não importa SDK nenhum e despacha tudo por uma porta
WebBackendinjetada. - Defender a defesa em profundidade: o backend mapeia defensivamente, o kernel re-valida com Zod e falha fechado numa linha ruim.
- Distinguir
ok([])(busca vazia) deerr(provedor caiu) — e por que confundi-los esconde uma queda real. - Aplicar o clamp
[1, 100]e o piso de compressão (DEFAULT_COMPRESS_MIN_LENGTH = 5000) sem desperdiçar chamada de modelo.
01 · Duas ferramentas, um kernel de despacho
Imagine um balcão de recepção. Quem chega faz um pedido ("busque isto", "leia aquela página"); a recepcionista não sai correndo atrás da informação — ela confere se o pedido faz sentido, repassa para o fornecedor certo, confere de novo o que voltou, e só então entrega. O kernel webSearch/webExtract é exatamente esse balcão: ele despacha, não resolve.
A moeda do subsistema: Result, nunca exceção
Tudo que pode falhar aqui devolve um Result<T, Error> de @alembic/contracts — uma união discriminada de dois formatos. Nada é lançado através da borda. Quem consome decide pelo campo .ok:
02 · As duas portas injetadas
A fonte (o web_tools.py) se constrói ao redor de duas costuras: um registro de provedores (Exa / Firecrawl / Parallel / Tavily) e um passo opcional de compressão por LLM. No CLONE, cada costura vira uma porta injetada. Este módulo não importa nenhum SDK e nenhum backend concreto — só os tipos:
// packages/hermes/src/web/types.ts:120-140 export interface WebBackend { 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 bruto
Repare na assimetria que o tipo já carrega: search devolve ok([]) para "sem resultados" mas err para "provedor caiu" — a diferença vive no tipo, exatamente como o data.web vazio da fonte. O Compressor espelha a costura ReviewProposer do loop de aprendizado: "uma chamada de modelo em prod, um fake em testes".
As duas portas, lado a lado
As duas costuras existem por motivos diferentes — uma para obrigatório e intercambiável, outra para opcional e caro:
| Aspecto | WebBackend | Compressor |
|---|---|---|
| Presença | Sempre (a busca precisa de alguém que resolva) | Opcional — compressor? |
| Papel | Resolve a rede: search / extract | Encolhe conteúdo grande p/ poupar tokens |
| Ausência ⇒ | impossível (sem backend não há busca) | ok(result) com conteúdo bruto |
| Análogo no engine | registro de provedores da fonte | ReviewProposer (learning/types.ts) |
Anatomia do que volta: WebSearchResult, campo por campo
O que o backend devolve é validado contra o webSearchResultSchema. São três campos, e cada um carrega uma regra — repare que só o url tem validação estrutural (não basta ser string, tem que ser uma URL):
O que mudou da fonte: a linha aparada
A fonte Python carrega {title, url, description, position} por linha. O CLONE apara: descarta o position e renomeia description para o idiomático snippet. Menos campos, nomes do motor:
03 · Defesa em profundidade: mapear, depois re-validar
Pense em duas catracas antes de a informação entrar no sistema. A primeira (o backend) é tolerante: nunca trava, transforma o que vier em algo de formato conhecido. A segunda (o kernel) é rígida: confere o contrato e barra qualquer linha estruturalmente inválida. O fluxo tem essas duas fronteiras — o backend fetch mapeia o JSON não confiável defensivamente, e depois o kernel re-valida cada linha com Zod:
// packages/hermes/src/web/web.ts:69-90 — webSearch 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 de backend não confiável if (!parsed.success) return err(new Error(`Invalid web search result: …`)); // 1 ruim ⇒ falha tudo validated.push(parsed.data); } return ok(validated);
url é a string "not-a-url" (o mapeador defensivo deixou passar como string). O que o webSearch devolve? Chute antes de revelar.
err ("Invalid web search result") — a chamada inteira falha, as 9 boas incluídas. O laço re-valida linha a linha com webSearchResultSchema; o z.string().url() reprova o "not-a-url", e o return dentro do laço aborta tudo na primeira reprovação. Não há descarte silencioso da linha ruim, nem exceção lançada — é fail-closed: melhor falhar visível do que entregar dados meio-corrompidos. Dois testes fixam isto: um url ausente e um url não-URL, ambos produzindo "Invalid web search result".url não-URL (que o mapeador defensivo alegremente passa adiante como string) ainda é rejeitado pelo z.string().url() do webSearchResultSchema. É a divisão de trabalho que deixa o mesmo kernel rodar contra qualquer backend.04 · Siga uma chamada de webSearch, passo a passo
Você viu o código e o fluxograma. Agora percorra uma execução real, devagar, do pedido ao Result — depois um caso é seu. Recuperar o procedimento (não só ver o resultado) é o que fixa de verdade.
webSearchQuerySchema.safeParse(query). O query não é vazio (passa o .min(1)) e maxResults é inteiro positivo → parsedQuery.success === true. Se fosse query: "", retornaria err("Invalid web search query") antes de tocar o backend.clampQuery(parsedQuery.data) reduz maxResults: 9999 para 100 (Math.min(Math.max(9999, 1), 100)). O backend nunca vê o 9999.await deps.backend.search(...). Se voltasse err ("provider down"), o kernel faria return found na hora — repassa a falha como está.raw em found.value, webSearchResultSchema.safeParse(raw). Toda linha válida → validated.push(parsed.data). Uma linha ruim → return err("Invalid web search result") e a chamada inteira aborta.return ok(validated). Se a lista estava vazia desde o início, isto é ok([]) — uma busca vazia bem-sucedida (seção 06).webExtract("https://x.test", { backend, compressor }) com uma página de 3000 chars e o piso padrão. O Compressor roda? Decida antes de revelar.
maybeCompress, result.content.length (3000) < minLength (5000) é verdadeiro, então retorna ok(result) com o conteúdo bruto, sem chamar o compressor. Dica: o piso existe justamente para não desperdiçar uma chamada de modelo em conteúdo pequeno — comprimir 3000 chars raramente economiza tokens.05 · O clamp [1, 100] — proteção na borda
Quanto um chamador pode pedir? A fonte limita com min(max(limit, 1), 100); o CLONE faz igual, e antes de o backend ver o número. Pedir 9999 não é erro — é silenciosamente limitado a 100:
// packages/hermes/src/web/web.ts:134-139 — clampQuery const clampQuery = (query) => { if (query.maxResults === undefined) return query; // ausente ⇒ passa direto const clamped = Math.min(Math.max(query.maxResults, MIN_RESULTS), MAX_RESULTS); // MIN=1, MAX=100 return { ...query, maxResults: clamped }; };
Arraste o slider: veja qualquer pedido cair na faixa [1, 100]. Abaixo de 1 sobe para 1; acima de 100 desce para 100; ausente passa direto. A barra mostra o valor que o backend realmente recebe:
O teste "clamps maxResults into [1, 100]" envia 9999 e 1 e afirma que o backend vê exatamente [100, 1] — a prova viva desta borda.
06 · Vazio ≠ erro: a distinção que protege você
Aqui mora uma decisão de design que parece pequena e é enorme. "Não achei nada" e "não consegui buscar" são coisas diferentes, e o tipo as mantém separadas: uma busca vazia é ok([]) (sucesso!), uma queda do provedor é err. Confundi-las esconderia uma queda real atrás de um inofensivo "sem resultados".
err: toda busca legítima sem resultados viraria um falso alarme — o agente trataria "nada encontrado" como "a internet quebrou" e abortaria à toa.ok([]): uma queda real do provedor passaria despercebida, mascarada como "sem resultados" — você nunca saberia que precisa de outro backend ou de um retry.07 · Compressão opcional — limitada por um piso
Para webExtract, um Compressor injetado pode encolher páginas grandes para poupar tokens. Mas comprimir custa uma chamada de modelo — então só vale a pena acima de um piso de tamanho. A regra é dupla: pula se não há compressor ou se o conteúdo está abaixo do piso (padrão 5000):
// packages/hermes/src/web/web.ts:118-132 — maybeCompress const minLength = deps.compressMinLength ?? DEFAULT_COMPRESS_MIN_LENGTH; // 5000 if (compressor === undefined || result.content.length < minLength) { return ok(result); // sem compressor / pequeno demais ⇒ bruto } const compressed = await compressor(result.content, instruction); if (!compressed.ok) return compressed; // falha do compressor ⇒ err (falha fechado) return ok({ ...result, content: compressed.value });
Três caminhos, decididos por dois testes simples. Siga o fluxograma de cabeça:
DEFAULT_COMPRESS_MIN_LENGTH = 5000, sobreponível por compressMinLength) garante que o custo do LLM só é pago quando há volume real para encolher. A instrução padrão é literal: "Summarize this web page, preserving all key facts, quotes, and code verbatim."08 · O backend fetch — zero dependência, zero socket no teste
A porta WebBackend precisa de uma implementação real para produção. É o createFetchBackend: ele fala uma API JSON genérica sobre o fetch global do Node (Node 18+) — sem node-fetch, sem SDK, sem nenhuma dep nova. É o único módulo aqui que toca a rede. E ainda assim os testes nunca abrem um socket, porque o próprio fetch é um campo de config injetável:
// packages/hermes/src/web/fetch-backend.ts:71-72 export const createFetchBackend = (config: FetchBackendConfig): WebBackend => { const doFetch = config.fetch ?? (globalThis.fetch as unknown as FetchLike); // default = global; teste injeta fake
O backend é fino: emite o request, eleva falhas de transporte para err, e mapeia o payload defensivamente. Três falhas de transporte viram err — provadas por três testes com fetch fake:
O mapeamento defensivo, por dentro
Como o backend "nunca lança no lixo"? Lendo cada campo com fallback e coerção. mapSearchRows aceita results ou web, e por linha lê snippet ou description; qualquer forma desconhecida vira '' (via asString) ou [] (via asArray):
O caminho do webExtract — linear, com a costura no fim
O webExtract é um pipeline reto: extrai pela porta, valida na borda, e só então decide sobre a compressão. Cada etapa pode curto-circuitar para err:
O description da fonte mapeia para o snippet idiomático do motor em exatamente um lugar (mapSearchRows, com fallback snippet ?? description). Um teste prova o mapeamento: dado o payload {results:[{title:'T', url:'https://r.test', description:'d'}]}, o webSearch devolve {title:'T', url:'https://r.test', snippet:'d'} — description:'d' entra, snippet:'d' sai. Tudo via fetch fake, sem socket.
09 · Os trade-offs, lado a lado
Toda a lição gira em torno de divisões deliberadas de responsabilidade. Veja-as explícitas: quem mapeia vs quem valida, e o que é o kernel vs o que NÃO é (o que ficou de fora do CLONE de propósito).
| Camada | Trabalho | No lixo (forma errada) | Dono do contrato? |
|---|---|---|---|
| backend (fetch) | Eleva transporte a err; mapeia payload em linhas aparadas | Nunca lança; colapsa forma desconhecida em '' | Não — fino por design |
| kernel (web.ts) | Valida pedido; clamp; re-valida cada linha com Zod; compressão opcional | Rejeita linha estruturalmente inválida (err) | Sim — é dono do contrato |
E o que não está aqui? O CLONE é o kernel de dados + despacho, não a plataforma inteira. A fonte (1378 LOC) tem muito mais — deliberadamente fora de escopo:
10 · Como isso se encaixa
Este subsistema não vive sozinho — ele é uma das mãos do agente no mundo. Quando o motor precisa de fato fresco (uma busca) ou de ler uma página (extract), é o webSearch/webExtract que atende, sempre pela porta injetada. Veja a peça (destacada) no lugar dela na máquina: o que a alimenta à esquerda, e o que ela alimenta à direita.
err a pipeline de gates (Lição 17) falha fechado em vez de raciocinar sobre lixo. Tudo isso só funciona porque ela segue a disciplina de portas & injeção (Lição 05) — o mesmo gabarito que o subsistema de mídia (Lição 13) também encarna. Veja o trajeto inteiro no mapa interativo da metodologia.Por que conecta: é o gabarito desta peça — a porta
WebBackend + o Compressor opcional são exatamente "injeta a costura, mantém o kernel puro". Sem essa disciplina, o kernel importaria um SDK.
Lição 13 · transcribe / analyzeImagePor que conecta: o subsistema-gêmeo — o mesmíssimo padrão (porta injetada → kernel fino → Result), só que para áudio/imagem em vez de busca/extract. Aprender um é aprender o outro. Lição 26 · Proveniência & segurança
Por que conecta: é o que o CLONE deixou de fora (a filtragem SSRF/URL-secreta da fonte, seção 09). A porta resolve a rede; a borda de egress precisa de uma camada à parte — esta lição é essa camada. Lição 02 · Engenharia reversa do Hermes
Por que conecta: esta peça É uma das tools que foram reverse-engineered — o
web_tools.py (1378 LOC) virou este kernel aparado. A Lição 02 é o método que produziu o CLONE.
Lição 07 · MemoryStorePor que conecta: o destino natural rio abaixo — o conteúdo que o
webExtract valida (e o Compressor encolhe) é o que vira memória semântica para o agente reusar depois.
11 · Na prática
alembic web. Este subsistema é uma biblioteca dentro de @alembic/hermes — o motor o chama por dentro, não há uma porta de CLI dedicada que dispare uma busca. A forma real de exercitá-lo de fora é rodar a suíte do pacote (que prova os 18 caminhos sem abrir socket) e ler/usar a API createFetchBackend + webSearch/webExtract. [uncertain] caso uma versão futura exponha um comando, ele apareceria no USAGE do apps/cli; hoje não existe.Rode a suíte que exercita este subsistema — ela injeta um fetch fake, então prova o mapeamento e os caminhos de falha sem tocar a rede:
# roda só o pacote @alembic/hermes — onde vive src/web/ (web.ts, fetch-backend.ts) pnpm --filter @alembic/hermes test # … # ✓ src/web/web.test.ts > clamps maxResults into [1, 100] (9999 → 100, 1 → 1) # ✓ src/web/web.test.ts > empty search is ok([]) , not err (vazio ≠ erro) # ✓ src/web/web.test.ts > rejects a row whose url is not a URL (err, fail-closed) # ✓ src/web/web.test.ts > skips the compressor below the floor (3000 < 5000 → bruto) # ✓ src/web/web.test.ts > fake fetch maps description → snippet (sem socket) # Test Files 1 passed # Tests 18 passed ← os 18 caminhos desta lição, todos com fetch FALSO
A contagem (18) é a desta lição na data de escrita; o que importa é que passa sem abrir um socket — o fetch injetável é o que torna isso possível. Para a baseline do monorepo inteiro (CLAUDE.md): pnpm -r typecheck && pnpm -r build && pnpm -w test.
E é assim que você usa a peça em código — a API real, do jeito que o motor a chama (a porta resolve a rede, o kernel re-valida):
// 1) construa o backend de produção (fetch global; nenhuma dep nova) import { createFetchBackend } from '@alembic/hermes'; import { webSearch, webExtract } from '@alembic/hermes'; const backend = createFetchBackend({ endpoint: 'https://api.example.com' }); // endpoint injetado, nunca hardcoded // 2) busque — o kernel valida o pedido, clampa p/ [1,100] e re-valida cada linha const hits = await webSearch({ query: 'alembic harness', maxResults: 50 }, { backend }); if (!hits.ok) return hits; // err propagado — provedor caiu (≠ busca vazia, que é ok([])) for (const r of hits.value) console.log(r.title, r.url, r.snippet); // 3) extraia uma página; passe um compressor opcional p/ encolher acima do piso (5000) const page = await webExtract('https://example.com/post', { backend }); // page.ok === true ⇒ page.value = { url, title, content }
Assinaturas reais: webSearch(query, { backend }) e webExtract(url, { backend, compressor? }) — ambas devolvem Result. Em teste, injeta-se fetch em createFetchBackend({ endpoint, fetch: fakeFetch }) e nenhum socket abre.
cd na raiz do monorepo e pnpm --filter @alembic/hermes test. Ele termina em segundos e não abre rede — os testes de src/web/web.test.ts injetam um fetch fake.packages/hermes/src/web/web.test.ts, procure onde o fetch fake é passado a createFetchBackend({ endpoint, fetch }). É a injeção da seção 08 acontecendo no teste — o backend devolve JSON enlatado, sem socket.url: 'not-a-url', e o teste afirma que webSearch retorna err ("Invalid web search result"). É o backend mapeando defensivo + o kernel re-validando (seção 03) — uma linha ruim falha a chamada inteira.pnpm -r typecheck && pnpm -r build && pnpm -w test na raiz. O typecheck é a metade silenciosa: ele recusa compilar se você ler hits.value sem ter tratado !hits.ok antes.webSearch/webExtract por dentro de uma unidade da missão — você dispara o motor com alembic run --goal GOAL.md --plan alembic.plan.ts --yes, e quando uma unidade precisa de fato fresco, esta peça atende pela porta injetada. [uncertain] não há um comando alembic web que isole só esta peça — o alembic run exercita o motor inteiro; para isolar este subsistema, o caminho direto é o pnpm --filter @alembic/hermes test acima.Fixe os conceitos (flashcards)
Clique pra virar. Tente lembrar a resposta antes de virar — recuperação ativa fixa mais que reler.
WebBackend injetada; quem resolve a rede é o backend (um fetch em prod, um fake em testes).err ("Invalid web search result"). O z.string().url() reprova; 1 linha ruim falha a chamada inteira. Nunca lança, nunca descarta em silêncio.ok([]) — sucesso. Só queda de provedor/rede é err. Fundir os dois esconderia uma queda real atrás de "sem resultados".compressor E content.length ≥ 5000 (DEFAULT_COMPRESS_MIN_LENGTH). Abaixo do piso ⇒ bruto. Falha do compressor ⇒ err.Simples vs técnico — escolha sua camada
A mesma ideia central, em dois níveis. Comece pelo simples; abra o técnico quando quiser a precisão:
webSearch/webExtract são despachantes puros sobre uma porta WebBackend injetada. Validam a entrada (safeParse), clampQuery a [1,100], re-validam cada linha da saída não confiável com Zod (fail-closed, 1 linha ruim aborta), e em webExtract aplicam um Compressor opcional acima de DEFAULT_COMPRESS_MIN_LENGTH = 5000. Tudo retorna Result<T, Error> de @alembic/contracts; nada é lançado através da borda. O backend de prod (createFetchBackend) usa globalThis.fetch por padrão, mas aceita um fetch injetado — por isso os 18 testes nunca abrem socket.Confusões comuns
createFetchBackend, e mesmo ele recebe um fetch injetável para que os testes nunca abram um socket.Revisão cumulativa — recupere de memória
Antes de clicar: responda de cabeça. As quatro opções têm o mesmo tamanho de propósito — sem pista pela forma.
url é a string "not-a-url". O que webSearch retorna?"not-a-url" falha o z.string().url() e uma linha ruim aborta tudo. a confunde "o backend mapeou" com "é válido" — o backend não valida o contrato, só normaliza forma. c inventa um descarte silencioso que não existe: o return dentro do laço falha a chamada toda, não pula a linha. d viola a regra de ouro do subsistema — nada é lançado através da borda; o erro vira err.webExtract recebe uma página de 3000 chars e um Compressor (piso padrão 5000). O que acontece?maybeCompress, content.length (3000) < minLength (5000) é verdadeiro, então retorna ok(result) com o bruto, sem chamar o modelo — comprimir conteúdo minúsculo desperdiça uma chamada. b ignora o piso: ter compressor não basta, o conteúdo também precisa clarear 5000. c inventa um erro: estar abaixo do piso é um caminho de sucesso, não de falha. d de novo supõe um throw que não existe — abaixo do piso é ok, normalíssimo.createFetchBackend sem abrir um socket?fetch é um campo injetável (config.fetch ?? globalThis.fetch); os testes passam um fakeFetch que devolve JSON fixo, exercitando o mapeamento e os caminhos de falha (HTTP 503, ECONNREFUSED) sem rede — espelha a injeção de idFactory no clarify. a erra a premissa: o backend não importa node-fetch — usa o fetch global do Node. b e d abririam (ou dependeriam de) rede de verdade; o ponto da injeção é justamente nunca tocar o socket.