Skip to content

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.

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.

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

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: 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:

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_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
/**
* 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
}