Course / Lesson 11  ·  PT-BR
Lesson 11 · Deep dive · subsystem 5 of 7

webSearch / webExtract — ports over fetch

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

Two ports, one kernel

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

Defense in depth: map defensively, then re-validate

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:

fetch backend POST · map rows UNTRUSTED JSON in webSearch / webExtract Zod re-validate · clamp · compress Result<rows> or err (1 bad row) trimmed rows backend never throws on garbage · kernel rejects garbage that's structurally invalid (e.g. a non-URL url)
// 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);
Why re-validate what the backend already mapped? The backend is "thin by design" — it lifts transport failures and maps payloads, but it does not own the contract. The kernel does. So a backend that returns a row with a non-URL 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".

The clamp, and the empty-vs-error distinction

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

Optional compression — gated by a size floor

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 });
The production backend has zero dependencies — and never opens a socket in tests

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

1. The backend returns a row whose url is the string "not-a-url". What does webSearch return?
Correct: b. Defense in depth: the thin backend maps payloads defensively, but the kernel owns the contract and re-validates every row. A non-URL url fails z.string().url(), and one bad row fails the entire call (never throws).
2. A search finds nothing. How is that represented, and how does it differ from a provider outage?
Correct: d. The type preserves the distinction: an empty result set is a success, a backend/network failure is a failure. Conflating them would hide a real outage behind "no results".
3. webExtract is given a 3000-char page and a Compressor (default floor 5000). What happens?
Correct: c. 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.

Common confusions

"This is a live web integration." No — it's the STRUCTURE of the source ported to ports-and-injection, not a wired provider. The kernel imports no SDK; the only network-touching module is createFetchBackend, and even that takes an injectable fetch so tests never open a socket.
"Double validation is wasteful." It's deliberate layering: the backend stays thin and never throws on garbage; the kernel owns the contract and rejects structurally-invalid data. Each layer has one job, and the split is what lets the same kernel run against any backend.