Curso / Lição 11
Lição 11 · Mergulho profundo · subsistema 5 de 7

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).

Leia primeiro (fonte de registro)
packages/hermes/src/web/ — types.ts · web.ts · fetch-backend.ts · web.test.ts

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.

Objetivos desta lição
  • Explicar por que o kernel não importa SDK nenhum e despacha tudo por uma porta WebBackend injetada.
  • 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) de err (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.
0
ferramentas · webSearch + webExtract
0 LOC
do web_tools.py clonado
0 dep
no kernel (e no backend fetch)
0
casos de teste (web.test.ts)

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.

O BALCÃO · o kernel despacha para a porta injetada e re-confere a volta
chamador webSearch(query) kernel (balcão) valida · clamp · re-valida NÃO importa SDK WebBackend porta injetada Result<rows> ou err (fail-closed) pedido linhas cruas O kernel é o único que conhece o contrato. O backend é intercambiável — qualquer impl que respeite a porta serve.
Por que isso importa? Porque o que resolve a web (um provedor de busca, um socket, uma chave de API) é volátil e impuro. Mantendo o kernel como puro despachante, ele fica testável, determinístico e independente de qualquer fornecedor. Trocar Exa por Tavily não toca uma linha do kernel — só injeta outro backend.

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:

RESULT · a união discriminada que substitui o throw (toda função do kernel devolve isto)
Result<T, Error> discrimina por .ok { ok: true, value: T } sucesso — ex.: as linhas validadas { ok: false, error: Error } falha — ex.: "Invalid web search result"

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:

WEBBACKEND (obrigatório) vs COMPRESSOR (opcional) — duas costuras, dois propósitos
WebBackend • OBRIGATÓRIO — sempre injetado • resolve a rede (search/extract) • intercambiável: Exa/Tavily/fetch • retorna Result · nunca lança • saída tratada como NÃO confiável Compressor • OPCIONAL — ausente ⇒ bruto • encolhe texto via 1 chamada LLM • só no extract, acima do piso • espelha o ReviewProposer • falha ⇒ err (falha fechado)
AspectoWebBackendCompressor
PresençaSempre (a busca precisa de alguém que resolva)Opcional — compressor?
PapelResolve a rede: search / extractEncolhe 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 engineregistro de provedores da fonteReviewProposer (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):

webSearchResultSchema · {title, url, snippet} — só o url é estruturalmente checado
title z.string() pode ser vazia url z.string().url() ↑ reprova "not-a-url" snippet z.string() era "description" na fonte O query schema é simétrico: query.min(1) (não-vazia) + maxResults inteiro positivo opcional.

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:

A LINHA APARADA · fonte Python {4 campos} → engine {3 campos} (position cai, description vira snippet)
fonte (web_tools.py) title url description position engine (WebSearchResult) title url snippet description → snippet (renomeado) · position é descartado

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:

FLUXO · webSearch → WebBackend → re-validação (duas catracas, da entrada ao Result)
JSON da rede NÃO confiável catraca 1 · backend mapeia defensivo forma estranha → '' catraca 2 · kernel Zod re-valida cada linha .url() · clamp alguma linha inválida? SIM err 1 ruim ⇒ falha tudo NÃO ok(linhas) validadas O backend nunca lança no lixo · o kernel rejeita lixo estruturalmente inválido (ex.: um url não-URL). Cada camada tem UM trabalho.
// 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);
Preveja antes de continuar
O backend devolve uma lista com 9 linhas perfeitas e 1 linha cujo 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".
Por que re-validar o que o backend já mapeou? O backend é "fino por design" — ele eleva falhas de transporte e mapeia payloads, mas não é dono do contrato. O kernel é. Então um backend que retorna uma linha com um 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.

Exemplo resolvido · webSearch({ query: "alembic", maxResults: 9999 }) com um backend OK
1
Valida o pedido. 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.
2
Aplica o clamp. clampQuery(parsedQuery.data) reduz maxResults: 9999 para 100 (Math.min(Math.max(9999, 1), 100)). O backend nunca vê o 9999.
3
Chama o backend injetado. await deps.backend.search(...). Se voltasse err ("provider down"), o kernel faria return found na hora — repassa a falha como está.
4
Re-valida linha a linha. Para cada 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.
5
Devolve. Todas as linhas passaram → return ok(validated). Se a lista estava vazia desde o início, isto é ok([]) — uma busca vazia bem-sucedida (seção 06).
Agora você: chame 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.
Não roda. Em 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:

0 MIN 1 MAX 100 (teto que o backend pode ver) 100

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".

VAZIO vs ERRO · o mesmo "nada na tela", dois significados opostos
busca não achou nada backend.search() → ok([]) ✓ SUCESSO (lista vazia) a busca rodou; só não houve hits provedor caiu backend.search() → err ✗ FALHA (propagada) a busca nem rodou de verdade Mesma "tela vazia" para o usuário — mas o tipo sabe a diferença, e quem consome o Result também.
Se fundisse os dois em err: toda busca legítima sem resultados viraria um falso alarme — o agente trataria "nada encontrado" como "a internet quebrou" e abortaria à toa.
Se fundisse os dois em 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:

FLUXOGRAMA · maybeCompress — bruto, comprimido, ou err (o piso 5000 no caminho)
extract validado entra maybeCompress(result) há compressor E len ≥ 5000? NÃO ok(result) conteúdo bruto SIM compressor deu ok? SIM ok({...result, content: comprimido}) NÃO return compressed (err — falha fechado)
Por que um piso, e não comprimir sempre? Porque comprimir conteúdo minúsculo desperdiça uma chamada de modelo e raramente reduz tokens. O piso (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
PROD vs TESTE · o mesmo createFetchBackend, dois fetch (real vs fake)
PRODUÇÃO config.fetch ausente → globalThis.fetch abre socket real POST → provedor de busca TESTE fetch: fakeFetch(...) injetado na config nenhum socket devolve JSON fixo · mapeamento testado Mesmíssimo código de mapeamento exercitado — espelha a injeção de idFactory no clarify.

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:

postJson · três falhas de transporte, todas elevadas a err (nunca lança)
throw de rede (ECONNREFUSED) status não-2xx (HTTP 503) JSON não parseável err kernel repassa (found.ok false)

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):

mapSearchRows · cadeia de fallback defensivo (forma estranha nunca quebra, vira '' ou [])
payload (unknown) JSON do provedor asArray(results ?? web) não-array ⇒ [] por linha → asString(...) title · url snippet ?? description não-string ⇒ '' (deixa o Zod decidir depois) Defensivo, não validador: ele normaliza FORMA. O contrato (url válida etc.) é re-checado no kernel.

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:

webExtract · extract → validar → maybeCompress (pipeline com saídas err)
backend.extract(url) !ok ⇒ return (err) safeParse (Zod) !success ⇒ err maybeCompress bruto / comprimido / err Result <WebExtractResult> Mesmo formato do webSearch — só a costura final muda: extract pode comprimir, search re-valida em laço. Diferença do schema: a linha de extract é {url, title, content} (o erro por-URL é o próprio err da linha, não um campo inline).
O rename de campo é real — e testado

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).

CamadaTrabalhoNo lixo (forma errada)Dono do contrato?
backend (fetch)Eleva transporte a err; mapeia payload em linhas aparadasNunca 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 opcionalRejeita 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:

O QUE É O KERNEL vs O QUE FICOU DE FORA (não-fabricação: ambos vêm do docstring da fonte)
DENTRO (o kernel) ✓ search / extract (despacho) ✓ porta WebBackend injetada ✓ re-validação Zod na borda ✓ clamp [1,100] ✓ compressão opcional (piso) ✓ backend fetch sem dep FORA (não-portado) ✗ auto-detect de 7 backends ✗ split por-capacidade ✗ descoberta de plugins ✗ log de debug-session ✗ filtragem SSRF/URL-secreta ✗ schema OpenAI + registro

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.

A PEÇA NO FLUXO · contracts define a forma → o backend resolve a rede → o KERNEL web despacha+re-valida → o conteúdo abastece o loop do agente, a memória e os gates
@alembic/contracts Result + a porta WebBackend (a forma · Lição 05) createFetchBackend a impl que resolve a rede (Exa/Tavily/fetch — injetável) define injeta ESTA PEÇA · web.ts webSearch / webExtract despacha pela porta · clamp re-valida (Zod) · Result conteúdo validado loop do agente · harness usa o fato fresco no raciocínio (Lição 19 · o swarm) memória semântica o extract vira conhecimento (Lição 07 · MemoryStore) pipeline de gates o err da peça falha fechado (Lição 17 · Proof/Validator) ↓ a mesma disciplina de ports-and-injection (Lição 05) sustenta TODA esta fileira — media (13) é o gêmeo deste padrão A peça é um despachante: ela não decide o que buscar nem o que fazer com o resultado — só garante que o que entra no motor é válido (ou um err honesto). Troque o backend (Exa→Tavily→local) e nada à direita muda: o contrato fino (a cintura, Lição 14) protege o downstream.
Clique para acender peça a peça — do que alimenta a porta (contracts + backend) ao que ela abastece (agente, memória, gates).
Onde você está na metodologia. No mapa do curso, esta é a quinta das sete portas do agente (subsistemas 07–13) — a que dá ao motor olhos na web. Ela fica rio acima do trabalho real: o loop do agente (Lição 19) pede um fato, esta peça o entrega validado, a memória (Lição 07) o arquiva, e se a peça devolve 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.

11 · Na prática

Honestidade primeiro: não há um comando 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.

Experimente · prove os caminhos desta peça sem rede (e veja o err honesto)
1
Entre no repo e rode a suíte do pacote. 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.
2
Abra o teste e ache o fake. Em 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.
3
Veja a catraca dupla em ação. Procure o caso "url não-URL": o fake devolve uma linha com 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.
4
Confirme a baseline. Rode 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.
O que você acabou de provar: o subsistema inteiro — clamp, vazio≠erro, re-validação, piso de compressão, falhas de transporte — roda idêntico sem nunca abrir um socket. É a porta injetável virando prova executável. E num run real?
Numa execução real, é o harness que chama 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 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.

Despacho
O que o kernel webSearch importa de SDK?
clique pra virar ↻
Resposta
Nada. Só os tipos. Ele despacha pela porta WebBackend injetada; quem resolve a rede é o backend (um fetch em prod, um fake em testes).
Defesa
Linha com url não-URL: o que webSearch faz?
clique pra virar ↻
Resposta
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.
Vazio vs erro
Busca sem hits: ok([]) ou err?
clique pra virar ↻
Resposta
ok([]) — sucesso. Só queda de provedor/rede é err. Fundir os dois esconderia uma queda real atrás de "sem resultados".
Compressão
Quando maybeCompress chama o LLM?
clique pra virar ↻
Resposta
Só se há 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:

Em uma frase: o kernel é um balcão que confere o pedido, repassa para um fornecedor trocável, confere de novo a resposta e entrega — sem nunca quebrar e sem nunca confiar cegamente no que o fornecedor manda.
Com 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

"Isto é uma integração web ao vivo." Não — é a ESTRUTURA da fonte portada para portas-e-injeção, não um provedor plugado. O kernel não importa SDK; o único módulo que toca a rede é o createFetchBackend, e mesmo ele recebe um fetch injetável para que os testes nunca abram um socket.
"Dupla validação é desperdício." É camada deliberada: o backend fica fino e nunca lança no lixo; o kernel é dono do contrato e rejeita dados estruturalmente inválidos. Cada camada tem um trabalho, e a divisão é o que deixa o mesmo kernel rodar contra qualquer backend.

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.

1. O backend retorna uma linha cujo url é a string "not-a-url". O que webSearch retorna?
Correto: b. Defesa em profundidade: o backend fino mapeia defensivamente, mas o kernel é dono do contrato e re-valida cada linha; "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.
2. Uma busca não encontra nada. Como isso é representado, e como difere de uma queda do provedor?
Correto: d. O tipo preserva a distinção: lista vazia é sucesso, falha de backend/rede é falha. a trataria toda busca legítima sem hits como falso alarme. b mascararia uma queda real atrás de "sem resultados" — você nunca saberia que precisa de retry/outro backend. c inverte tudo e ainda introduz um throw, que o subsistema proíbe na borda.
3. webExtract recebe uma página de 3000 chars e um Compressor (piso padrão 5000). O que acontece?
Correto: a. Em 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.
4. Como os 18 testes exercitam o createFetchBackend sem abrir um socket?
Correto: c. O 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.
💬 Travou em algo? Eu sou seu professor neste curso — pergunte. "Por que re-validar se o backend já mapeou?", "Como eu plugaria o Tavily como WebBackend?", "O que o piso de 5000 economiza na prática?". É só dizer.