Flow: A web_search call: route, authenticate, search, fall back, label
One web_search call is the model asking for the live web through a sanctioned egress: one of the two network tools the bash guard denies the ad-hoc clients for. The extension reads the committed config ONCE per session and fails closed (the user’s ENSO_HOME/config.json, and a trusted project’s .enso/config.json over it; absent means defaults, unreadable means every call refuses and no provider runs). Per call it routes the CURRENT MODEL to its own native search API, resolves auth through the runtime’s model registry, and, only when TAVILY_API_KEY sits in ENSO_HOME/secrets.env, arms one Tavily attempt behind it. The result’s first line names the backend that answered and, on fallback, why; an answer produced without searching is labeled, never rendered as a search result (#63). One deadline covers both attempts (#99); one audit record per call.
The sequence
Section titled “The sequence”The current model searches
Section titled “The current model searches”Recorded by web-search.test.ts: a searched primary renders the via-line, the notice, the answer, and the sources. Arrows are what the test drove and what its doubles received; notes are what the code logged at debug and above, in the order it logged them.
sequenceDiagram
participant R as runtime (a tool call inside a run)
participant X as web_search (extensions/web-search/index.ts)
participant S as ENSO_HOME/secrets.env (readSecret)
participant P as native provider · Tavily (providers/*.ts)
R->>X: execute({ query }, signal, onUpdate, { model: anthropic-messages })
X->>S: readSecret(TAVILY_API_KEY)
S->>X: absent: the fallback is not armed
X->>P: runAnthropicProviderSearch({ query, signal }) under the call's one deadline
P->>X: WebSearchOutcome { kind: "searched", providerId: "anthropic" }
Note over X: audit: searched provider anthropic — primary
X->>R: Searched the web via anthropic.
An ungrounded answer falls back to Tavily
Section titled “An ungrounded answer falls back to Tavily”Recorded by web-search.test.ts: ungrounded WITH Tavily armed falls back, and the render says why. Arrows are what the test drove and what its doubles received; notes are what the code logged at debug and above, in the order it logged them.
sequenceDiagram
participant R as runtime (a tool call inside a run)
participant X as web_search (extensions/web-search/index.ts)
participant S as ENSO_HOME/secrets.env (readSecret)
participant P as native provider · Tavily (providers/*.ts)
R->>X: execute({ query }, signal, onUpdate, { model: anthropic-messages })
X->>S: readSecret(TAVILY_API_KEY)
S->>X: present: the fallback is armed
X->>P: runAnthropicProviderSearch({ query, signal }) under the call's one deadline
P->>X: WebSearchOutcome { kind: "ungrounded", providerId: "anthropic" }
X->>P: runTavilyProviderSearch({ query, signal }) under the call's one deadline
P->>X: WebSearchOutcome { kind: "searched", providerId: "tavily" }
Note over X: audit: searched provider tavily — fallback: the anthropic backend answered WITHOUT searching (the model used its own knowledge)
X->>R: Searched the web via tavily (fallback — the anthropic backend answered WITHOUT searching (the model used its own knowledge)).
The invariants it shows
Section titled “The invariants it shows”Selection is the current model’s own API, or the configured pin, never implicit.
Section titled “Selection is the current model’s own API, or the configured pin, never implicit.”DeepSeek runs only when the user’s config says webSearch.backend: "deepseek"; otherwise routedProviderIdForModel reads model.provider and model.api (Google by either, the three Responses APIs to openai, anthropic-messages to anthropic), and anything else (the runtime registers DeepSeek chat models as openai-completions) selects nothing: a failure record. The google and openai branches are driven through the tool; the absent-model record is not.
Stated at runPrimary in packages/harness/extensions/web-search/index.ts.
Pinned by:
web-search.test.ts: a Gemini model routes to the google provider through the tool, and the audit names it
web-search.test.ts: every Responses-family api routes to the openai provider through the tool: openai, azure, codex
web-search.test.ts: an unrouted model with no Tavily key refuses, naming the arming env var
The fallback is armed by a secret’s presence, never by config, and fires on three things.
Section titled “The fallback is armed by a secret’s presence, never by config, and fires on three things.”TAVILY_API_KEY is read per call through readSecret over ENSO_HOME/secrets.env, never process.env, which the host has quarantined (#89); present and non-empty arms it. This is the whole policy: searched never falls back; ungrounded, an unrouted model and a thrown provider error all do. The key reaches neither the rendered text nor the audit record.
Stated at fallbackReasonFor in packages/harness/extensions/web-search/index.ts.
Pinned by:
web-search.test.ts: ungrounded with no Tavily key: labeled as NOT a search result, no notice, no via-line
web-search.test.ts: ungrounded WITH Tavily armed falls back, and the render says why
web-search.test.ts: an unrouted model with no Tavily key refuses, naming the arming env var
One deadline, one progress update, one audit record.
Section titled “One deadline, one progress update, one audit record.”WEB_SEARCH_TIMEOUT_MS (60 s) is set once per call and covers both attempts, so primary and fallback stay under the server’s 90 s no-progress cutoff (#98): a primary that stalls for the whole budget leaves the fallback an already-dead signal. The fallback’s announcement is tool progress (#99). auditLine logs warn for a refusal and info otherwise: exactly one record per call, naming provider and outcome (#132).
Stated at underDeadline in packages/harness/extensions/web-search/index.ts.
Pinned by:
web-search.test.ts: a searched primary renders the via-line, the notice, the answer, and the sources
web-search.test.ts: ⚠ a primary that stalls for the whole budget leaves the fallback NO time — it is refused with the deadline, not run for another budget
The render is honest about who answered and what it may have cost.
Section titled “The render is honest about who answered and what it may have cost.”The first line names providerId; on fallback it appends the reason, which says the primary may have billed when requestDispatched is true (set in dispatchProviderPost: false when the request failed to send, true when the API answered non-OK). EXTERNAL_WEB_CONTENT_NOTICE (shared with web_fetch) is the second section of every searched result; renderUngrounded omits it and the via-line, and labels the answer as NOT a search result.
Stated at renderOutcome in packages/harness/extensions/web-search/index.ts.
Pinned by:
web-search.test.ts: a searched primary renders the via-line, the notice, the answer, and the sources
web-search.test.ts: ungrounded with no Tavily key: labeled as NOT a search result, no notice, no via-line
searched vs ungrounded is keyed on search activity, never on source count.
Section titled “searched vs ungrounded is keyed on search activity, never on source count.”A server_tool_use block on the wire is the evidence a native search ran (each provider names its own signal); a citation with no search block is ungrounded, an errored search is searched. WebSearchOutcome (vocabulary.ts) is the contract.
Stated at runAnthropicWireSearch in packages/harness/core/web-search/anthropic-wire.ts.
Pinned by:
web-search-wire.test.ts: an answer with a citation but NO search block isungrounded— sources are not the key
Auth is the conversation’s identity; the provider env key is the last resort.
Section titled “Auth is the conversation’s identity; the provider env key is the last resort.”This asks the runtime’s model registry, passes a failure through, drops null-valued headers, and reads the environment only when neither a key nor an authorization / x-api-key / x-goog-api-key header came back. The extension builds the resolver into ModelSearchRequest (resolveAuth), and the provider, not the extension, decides when to call it.
Stated at resolveModelAuth in packages/harness/core/web-search/auth.ts.
Pinned by:
web-search-support.test.ts: resolveModelAuth falls back to the provider env key — and only when nothing else authenticates
web_fetch, the sibling, gates cheapest-first and each refusal is final.
Section titled “web_fetch, the sibling, gates cheapest-first and each refusal is final.”URL parse, then the config problem, then the empty allowlist, then the exact host match, then the advisory public-address gate (one non-public answer refuses), then one fetch with redirect: 'manual': a redirect is reported with its target, never followed.
Stated at execute in packages/harness/extensions/web-fetch/index.ts.
Pinned by:
web-fetch.test.ts: a redirect is REPORTED with its absolute target and never followed
The contract
Section titled “The contract”/** * 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 }Where it is stated
Section titled “Where it is stated”createWebSearchinpackages/harness/extensions/web-search/index.ts: the flowrunPrimaryinpackages/harness/extensions/web-search/index.ts: model-selectsfallbackReasonForinpackages/harness/extensions/web-search/index.ts: secret-arms-fallbackunderDeadlineinpackages/harness/extensions/web-search/index.ts: one-deadline-one-auditrenderOutcomeinpackages/harness/extensions/web-search/index.ts: honest-renderrunAnthropicWireSearchinpackages/harness/core/web-search/anthropic-wire.ts: searched-by-activityresolveModelAuthinpackages/harness/core/web-search/auth.ts: auth-identityexecuteinpackages/harness/extensions/web-fetch/index.ts: web-fetch-gates- the seam and its fences
- the config knobs and the secrets file
- the words: egress and secret (Permission)
- the deadline and the arming rule
- the shape in one pass (the file’s header)
