Two tools — search the web, extract a page — built as a thin dispatch kernel over an injected WebBackend port, with an optional Compressor seam for token-saving LLM summarization. The kernel resolves nothing itself: it validates the request, calls the injected backend, re-validates the untrusted result with Zod, and returns a Result. The production backend is a dependency-free fetch impl. A CLONE of Hermes' web_tools.py (1378 LOC).
The source builds around two seams — a provider registry and an optional LLM-compression pass. Both become injected ports; this module imports no SDK and no concrete backend:
// 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>>; // optional; absent ⇒ raw content
A network failure is err; an empty search is ok([]) — the difference is preserved in the type, exactly like the source's empty data.web. The Compressor mirrors the ReviewProposer seam from the learning loop: "a model call in prod, a fake in tests."
The flow has two boundaries. The fetch backend maps untrusted JSON defensively (unknown shapes → empty strings); then the kernel re-validates every row with Zod, failing the whole call closed on a single bad row:
// 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); // untrusted backend row if (!parsed.success) return err(new Error(`Invalid web search result: …`)); // 1 bad ⇒ fail all validated.push(parsed.data); } return ok(validated);
url (which the defensive mapper happily passes through as a string) is still rejected by webSearchResultSchema's z.string().url(). Two tests pin this: a missing url and a non-URL url both produce "Invalid web search result".maxResults is clamped to [1, 100] before the backend ever sees it — the source's min(max(limit, 1), 100):
// packages/hermes/src/web/web.ts:134-139 — clampQuery const clampQuery = (query) => { if (query.maxResults === undefined) return query; // absent ⇒ pass through const clamped = Math.min(Math.max(query.maxResults, 1), 100); return { ...query, maxResults: clamped }; };
The test "clamps maxResults into [1, 100]" sends 9999 and 1 and asserts the backend sees exactly [100, 1]. An empty result set is ok([]), not an error — "no hits" is a successful search, distinct from "the provider is down" (err).
For extracts, an injected Compressor can shrink large pages to save tokens. It's skipped below a size floor (compressing small content wastes a model call) and absent by default:
// 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); // no compressor / too small ⇒ raw } const compressed = await compressor(result.content, instruction); if (!compressed.ok) return compressed; // compressor failure ⇒ err (fail closed) return ok({ ...result, content: compressed.value });
createFetchBackend speaks a generic JSON API over Node's global fetch (Node 18+) — no node-fetch, no SDK, no new dep. The endpoint and key are injected (never hardcoded), and fetch itself is an injectable config field defaulting to the global. So tests pass a fake fetch and exercise the full request/response mapping without a network call (mirroring the idFactory injection in clarify). A network throw, a non-2xx status, and unparseable JSON each become err — proven by three transport tests. And the field rename is real: the source's description maps to the engine-idiomatic snippet in exactly one place, with a test asserting snippet:'d' from input description:'d'.
url is the string "not-a-url". What does webSearch return?url fails z.string().url(), and one bad row fails the entire call (never throws).webExtract is given a 3000-char page and a Compressor (default floor 5000). What happens?maybeCompress returns the row unchanged when there's no compressor or the content is below compressMinLength (default DEFAULT_COMPRESS_MIN_LENGTH = 5000). Compressing tiny content wastes a model call.createFetchBackend, and even that takes an injectable fetch so tests never open a socket.