Seam: web providers
The provider seam under the harness: five search backends behind one contract and one vocabulary, selected by the tool that owns the fallback policy, and the fetch tool’s injected IO beside it. Glossary domains: Permission (egress — the two sanctioned network calls the bash guard denies by name; a committed allowlist and a committed backend pin decide what may run) and Control (a tool call inside a run). This directory is the pattern the consolidation (#162) copied elsewhere — where we already did it right: a normalizer in our own code that has held.
Every fence on this page is the source, checked by test/docs-gate.test.ts. Refresh with
bun scripts/docs/refresh-fences.ts docs/seams/web-providers.md.
The contract
Section titled “The contract”The vocabulary (packages/harness/core/web-search/vocabulary.ts) exports, top level:
WebSearchProviderId, WebSource, WebSearchSnippet, WebSearchOutcome, WebSearchProviderError,
FetchUrlInit, FetchUrl, ResolvedProviderAuth, ModelSearchRequest, routedProviderIdForModel.
Its header (lines 3-5) states the one structural rule: providers live in ./providers/, one module
each, and MUST NOT import each other. The four that are the contract:
/** * Every backend that can answer a web_search call. */export type WebSearchProviderId = 'anthropic' | 'google' | 'openai' | 'deepseek' | 'tavily'/** * What one provider run produced — the ACCURATE outcome union (#63, Daniel's * requirement). * * ⚠ `searched` vs `ungrounded` is keyed on evidence that a native search actually ran (a * `server_tool_use` block on the Anthropic wire; each provider names its own signal), NEVER on * source count: a real search can return zero results, and an ungrounded answer citing a * hallucinated URL would read as grounded. Upstream's `grounded = sources.length > 0` had * exactly that defect. */export type WebSearchOutcome = | { kind: 'searched' providerId: WebSearchProviderId /** The provider-synthesized answer, citation-marked. Absent for retrieval backends (Tavily). */ answerText?: string sources: WebSource[] /** Retrieval results with snippets — the Tavily shape; model-native backends leave it absent. */ snippets?: WebSearchSnippet[] } | { /** The model answered WITHOUT searching — its own knowledge, not the live web. */ kind: 'ungrounded' providerId: WebSearchProviderId answerText: string }/** * Thrown by provider runs on failure. * * `requestDispatched` says whether the HTTP request left the process — an honest "may have * billed" bit (whether a failed request billed is the provider's accounting, not knowable * here), surfaced when the fallback re-searches. */export class WebSearchProviderError extends Error { readonly providerId: WebSearchProviderId readonly requestDispatched: boolean
constructor(providerId: WebSearchProviderId, message: string, options: { requestDispatched: boolean }) { super(message) this.name = 'WebSearchProviderError' this.providerId = providerId this.requestDispatched = options.requestDispatched }}/** * What a MODEL-ROUTED provider run receives (anthropic / google / openai). */export interface ModelSearchRequest { query: string model: SearchModel resolveAuth: (model: SearchModel) => Promise<ResolvedProviderAuth> fetchUrl: FetchUrl signal: AbortSignal | undefined /** Streaming progress for the UI; carries the accumulated answer text so far. */ onProgress?: ((text: string) => void) | undefined}The two request shapes that are not model-routed are DeepseekSearchRequest (providers/deepseek.ts:29-36:
query, the key read at the composition root, fetchUrl, signal, progress) and TavilySearchRequest
(providers/tavily.ts:22-29: query, key, the two domain lists, fetchUrl, signal). Neither carries a
model; the config-pinned backend and the fallback are selected without one.
What the two extensions take at their composition roots — the IO seams tests replace:
/** The IO seams. Provider runs are injectable so the fallback policy is testable alone. */export interface WebSearchDependencies { /** Where `enso.config.json` lives. May throw; the extension fails CLOSED on that. */ locateConfigFile: () => string readConfigText: (path: string) => string /** Env reads happen HERE (composition root) — providers receive values, never names. */ readEnvironmentVariable: (variableName: string) => string | undefined runProviders: { anthropic: (request: ModelSearchRequest) => Promise<WebSearchOutcome> google: (request: ModelSearchRequest) => Promise<WebSearchOutcome> openai: (request: ModelSearchRequest) => Promise<WebSearchOutcome> deepseek: (request: DeepseekSearchRequest) => Promise<WebSearchOutcome> tavily: (request: TavilySearchRequest) => Promise<WebSearchOutcome> } fetchUrl: ModelSearchRequest['fetchUrl'] /** The whole-call deadline. Defaults to `WEB_SEARCH_TIMEOUT_MS`; injectable so the chain's budget is testable in milliseconds. */ timeoutMs?: number}/** * The IO seams, injected so tests never touch the network, DNS or the real config file — * same pattern as the guards' `analyzeBashCommand`. */export interface WebFetchDependencies { /** * Where `enso.config.json` lives — read from AND named in refusal messages. May throw * (a broken install has no findable root); the extension fails CLOSED on that. */ locateConfigFile: () => string readConfigText: (path: string) => string fetchUrl: (url: string, init: WebFetchRequestInit) => Promise<Response> /** Resolve a hostname to its addresses. Rejects on NXDOMAIN/timeouts. */ resolveHostAddresses: (hostname: string) => Promise<string[]>}The config sections, in @enso/core, composed into the one committed file by config.ts:40-43
(webFetch required, webSearch optional). WebSearchConfig (core/src/web-search.ts:55-70) is
two optional knobs and nothing else: backend: "deepseek" — the ONLY way that backend is selected,
so no provider is billed the file never named — and tavily.includeDomains / excludeDomains, passed
verbatim; the fallback is armed by the key’s presence, never by config, because a key is a secret
(header lines 47-53). The section must stay optional (42-45): the whole-file schema is closed and
fail-closed, so a required new section would make every committed config unreadable — and an
unreadable config refuses all web_fetch calls too.
/** * web_fetch's section of `enso.config.json`. * * `allowedHosts` are exact hostnames (case-insensitive, no wildcards, no ports) — each entry is * one reviewable grant. Strict on unknown keys for the same reason the whole config is — see * `config.ts`. */export const WebFetchConfig = Type.Object( { allowedHosts: Type.Array(Type.String()), }, { additionalProperties: false },)Who implements it, who consumes it
Section titled “Who implements it, who consumes it”Five providers, one run* function each, all top level and all exported through the harness
core barrel (packages/harness/core/index.ts:45-58):
| Provider | Function | Takes | Wire |
|---|---|---|---|
anthropic |
providers/anthropic.ts#runAnthropicProviderSearch |
ModelSearchRequest |
the shared anthropic-wire.ts |
google |
providers/google.ts#runGoogleProviderSearch |
ModelSearchRequest |
Gemini grounding, own parse |
openai |
providers/openai.ts#runOpenAiProviderSearch |
ModelSearchRequest |
Responses API, own parse |
deepseek |
providers/deepseek.ts#runDeepseekProviderSearch |
DeepseekSearchRequest |
the shared anthropic-wire.ts |
tavily |
providers/tavily.ts#runTavilyProviderSearch |
TavilySearchRequest |
one POST, own parse |
Each returns a WebSearchOutcome or throws a WebSearchProviderError with its requestDispatched
bit. anthropic-wire.ts is shared by exactly two: providers/anthropic.ts:1 imports
runAnthropicWireSearch and the tool type, providers/deepseek.ts:1 imports runAnthropicWireSearch;
google.ts, openai.ts and tavily.ts do not. Its header (lines 11-15) says why: DeepSeek’s search
endpoint speaks the Anthropic Messages wire at its own base, so callers own endpoint, headers, model id
and budgets, and the wire module owns the request body, the stream parse and the searched/ungrounded
verdict — keyed on server_tool_use blocks having appeared, never on source count (lines 20-24).
The web_search extension is the one consumer and owns selection (packages/harness/extensions/web-search/index.ts).
createWebSearch (115-232) loads the config once at extension load and fails closed; runPrimary
(254-308) picks the backend: webSearch.backend: "deepseek" pins the dedicated backend (257), else the
current model routes through routedProviderIdForModel (273) and its auth through resolveModelAuth
(289-294), and a model with no native search selects nothing (274-281). The composition root (419-436)
wires the five run* functions into runProviders, real fetch into fetchUrl, and
readEnvironmentVariable to readSecret over .enso/secrets.env — never process.env (422-427): the
host quarantines secrets out of the environment before a session exists, and providers receive values,
never names.
web_fetch consumes its own four seams (web-fetch/index.ts:68-78): locateConfigFile and
readConfigText are called once inside createWebFetch (93-94), so the allowlist is captured at load
and an in-session edit is inert (header lines 32-34); resolveHostAddresses feeds the advisory
public-address gate (addressGateReason, 178-202); fetchUrl is called once per call under
fetchUnderDeadline (205-231) with redirect: 'manual' and WEB_FETCH_TIMEOUT_MS. The pure policy —
URL gate, host match, content-type gate, the address classifier — is harness/core/web-fetch-policy.ts.
The second implementation is every test. web-search.test.ts (extensions/web-search/__tests__/)
drives createWebSearch with scripted runProviders and readConfigText — a searched primary, an
ungrounded one with and without the fallback armed, a post-dispatch failure that admits it may have
billed, both backends failing, the config-pinned backend, an unreadable config failing closed;
web-fetch.test.ts drives createWebFetch with fake DNS, fetch and config; core/__tests__/web-search-model-providers.test.ts
drives the three model-routed run* functions over scripted SSE, and web-search-wire.test.ts the shared wire.
What fences it
Section titled “What fences it”- The vendor gate’s import allowlist (
test/vendor-gate.test.ts#VENDOR_IMPORT_ALLOWED) is the fence, and it is empty — the gate keeps it so, and its header says why. Any file underharness/coreimporting@earendil-works/*is a red test that names the file; adding a row back is adding a dependency site and must state its reason. The two entries this seam used to hold, and how they left, are below. - The one deadline (#99).
WEB_SEARCH_TIMEOUT_MS = 60_000(core/src/web-search.ts:26) is the budget for primary AND fallback together, not per attempt, and its header (19-24) says why: a per-attempt 60 s let primary + fallback run 120 s with no chunk crossing, and the server’s 90 s no-progress cutoff read that as a hung run (#98).createWebSearchsets oneCallDeadlineper call (162-163);underDeadline(333-349) runs every attempt under what remains of it and the caller’s signal, and a spent budget aborts before the attempt runs (harness/core/deadline.ts:25, header 8-9) — a 0 ms timer would let a fast provider answer first. All three attempts go through it (212, 258, 299), and two tests pin it: a primary that stalls for the whole budget leaves the fallback no time (web-search.test.ts:377), a primary that fails fast leaves it the rest (413). - The independence rule (
vocabulary.ts:3-5): providers do not import each other. Not gated; visible in the five files’ imports today, and why a later split into provider packages is mechanical.
What crosses it that should not
Section titled “What crosses it that should not”Nothing of the runtime’s, since L6 closed (#162). ModelSearchRequest.model is SearchModel —
the seven fields the providers read, declared in vocabulary.ts as ours — and resolveAuth takes it;
resolveModelAuth takes the environment-key reader as a parameter instead of importing the runtime’s.
The runtime’s model record is converted once, at the composition root
(extensions/web-search/index.ts#searchModelOf), and the registry is asked with the runtime’s record
inside a closure the provider never sees. harness/core imports nothing from @earendil-works/*; the
vendor gate’s import allowlist is empty. Until then vocabulary.ts and auth.ts imported pi-ai’s
Api/Model types and getEnvApiKey, allowlisted with the reason leaves when web-search’s provider
contract names its own model/credential types — which is what happened. auth.ts’s header still
records the wire fact behind the env fallback: the runtime’s registry returns { ok: true } with no
key when auth comes only from provider env vars, so this code mirrors that fallback because it calls
fetch directly.
The composition roots import the runtime’s extension API (ExtensionAPI, AgentToolResult,
ExtensionContext) — under packages/harness/extensions/, a root the vendor gate permits, and the
shape Leak numbers records as L5 for the guards: the registration is
the runtime’s, the contract behind it is ours.
Nothing else. The outcome union, the error, the two request shapes and both dependency interfaces
name no vendor; anthropic-wire.ts names a provider’s HTTP wire, which is the thing being adapted.
Pivot cost
Section titled “Pivot cost”Adding a provider is one file under providers/ exporting a run* over ModelSearchRequest (or
its own request shape, as deepseek and tavily do), one literal on WebSearchProviderId, one entry
in WebSearchDependencies.runProviders and the composition root’s map, and — for a model-routed one —
a branch in routedProviderIdForModel. The extension’s selection, fallback, deadline and rendering
do not change; web-search.test.ts runs against scripted providers and does not change either.
Replacing the model API — a runtime whose model registry has a different shape — changes
vocabulary.ts (ModelSearchRequest.model, ResolvedProviderAuth, routedProviderIdForModel, which
reads model.provider and model.api) and auth.ts (ModelAuthSource, resolveModelAuth), plus the
one call site that builds a ModelSearchRequest from the runtime’s context
(extensions/web-search/index.ts:286-298).
The five providers read only request.model fields through the request and resolveAuth; the wire
modules, the outcome union, the config sections, web_fetch and every test that scripts a provider do
not change. That pivot has already been paid once: it is what deleted the allowlist’s last two rows.