Duas ferramentas — buscar na web, 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 retorna um Result. O backend de produção é uma impl fetch sem dependências. Um CLONE do web_tools.py do Hermes (1378 LOC).
A fonte se constrói ao redor de duas costuras — um registro de provedores e um passo opcional de compressão LLM. Ambas viram portas injetadas; este módulo não importa nenhum SDK e nenhum backend concreto:
// 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
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 a costura ReviewProposer do loop de aprendizado: "uma chamada de modelo em prod, um fake em testes".
O fluxo tem duas fronteiras. O backend fetch mapeia JSON não confiável defensivamente (formas desconhecidas → strings vazias); depois o kernel re-valida cada linha com Zod, falhando a chamada inteira fechada numa única linha ruim:
// 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 não-URL (que o mapeador defensivo alegremente passa adiante como string) ainda é rejeitado pelo z.string().url() do webSearchResultSchema. Dois testes fixam isto: um url ausente e um url não-URL ambos produzem "Invalid web search result".maxResults é limitado a [1, 100] antes do backend ver — o min(max(limit, 1), 100) da fonte:
// 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, 1), 100); return { ...query, maxResults: clamped }; };
O teste "clamps maxResults into [1, 100]" envia 9999 e 1 e afirma que o backend vê exatamente [100, 1]. Um conjunto de resultados vazio é ok([]), não um erro — "sem resultados" é uma busca bem-sucedida, distinta de "o provedor caiu" (err).
Para extracts, um Compressor injetado pode encolher páginas grandes para economizar tokens. É pulado abaixo de um piso de tamanho (comprimir conteúdo pequeno desperdiça uma chamada de modelo) e ausente por padrão:
// 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 });
createFetchBackend fala uma API JSON genérica sobre o fetch global do Node (Node 18+) — sem node-fetch, sem SDK, sem dep nova. O endpoint e a key 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 fake e exercem o mapeamento request/response inteiro sem uma chamada de rede (espelhando a injeção de idFactory no clarify). Um throw de rede, um status não-2xx, e JSON não parseável cada um vira err — provado por três testes de transporte. E o rename de campo é real: o description da fonte mapeia para o snippet idiomático do motor em exatamente um lugar, com um teste afirmando snippet:'d' a partir da entrada description:'d'.
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 retorna 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.createFetchBackend, e mesmo ele recebe um fetch injetável para que os testes nunca abram um socket.