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 at load and fails closed; 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/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”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/secrets.env (readSecret)
participant V as core/web-search (vocabulary.ts, auth.ts)
participant P as native provider · Tavily (providers/*.ts)
Note over X: at load: enso.config.json read ONCE · unreadable ⇒ every call refuses, no provider runs
R->>X: execute({ query }, signal, onUpdate, context)
X->>S: TAVILY_API_KEY — the fallback is armed iff present and non-empty
X->>V: routedProviderIdForModel(context.model) — unless config pins backend "deepseek"
V-->>X: anthropic | google | openai | undefined (no native search)
X->>P: run<Provider>Search({ query, model, resolveAuth, fetchUrl, signal, onProgress }) under ONE deadline
P->>V: resolveAuth(model) ⇒ resolveModelAuth(context.modelRegistry, model)
P-->>X: WebSearchOutcome, or WebSearchProviderError{ requestDispatched }
alt searched
X-->>R: "Searched the web via <provider>." + notice + answer + sources
else ungrounded · no route · failure, Tavily NOT armed
X-->>R: "No web search was performed …" | "Not searched: … TAVILY_API_KEY in .enso/secrets.env"
else Tavily armed
X->>P: runTavilyProviderSearch, announced as progress, under what the primary LEFT of the deadline
X-->>R: "Searched the web via tavily (fallback — <reason>)." | "Not searched: <both reasons>"
end
The invariants it shows
Section titled “The invariants it shows”- Selection is the current model’s own API, or the committed pin — never implicit.
runPrimaryruns DeepSeek only whenenso.config.jsonsayswebSearch.backend: "deepseek"; otherwiseroutedProviderIdForModelreadsmodel.providerandmodel.api— Google by either, the three Responses APIs toopenai,anthropic-messagestoanthropic— and anything else (the runtime registers DeepSeek chat models asopenai-completions) selects nothing: a failure record. Thegoogleandopenaibranches are driven through the tool (below); the absent-model record is not. - Auth is the conversation’s identity; the provider env key is the last resort.
resolveModelAuthasks the runtime’s model registry, passes a failure through, drops null-valued headers, and reads the environment only when neither a key nor anauthorization/x-api-key/x-goog-api-keyheader came back. The resolver travels insideModelSearchRequest; the extension never calls it. - The fallback is armed by a secret’s presence, never by config, and fires on three things.
TAVILY_API_KEYis read per call throughreadSecretover.enso/secrets.env— neverprocess.env, which the host has quarantined (#89).fallbackReasonForis the whole policy:searchednever falls back;ungrounded, an unrouted model and a thrown provider error all do. searchedvsungroundedis keyed on search activity, never on source count. The fence below is the contract; a citation with no search block isungrounded, an errored search issearched.- 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 whenrequestDispatchedis true — set indispatchProviderPost: false when the request failed to send, true when the API answered non-OK.EXTERNAL_WEB_CONTENT_NOTICE(shared withweb_fetch) is the second section of everysearchedresult;renderUngroundedomits it and the via-line. - One deadline, one progress update, one audit record.
WEB_SEARCH_TIMEOUT_MS(60 s) covers both attempts to stay under the server’s 90 s no-progress cutoff (#98); the fallback’s announcement is tool progress (#99).auditLinelogswarnfor a refusal,infootherwise — a record (#132). web_fetch, the sibling, gates cheapest-first and each refusal is final: URL parse → config problem → empty allowlist → exact host match → the advisory public-address gate (one non-public answer refuses) → one fetch withredirect: 'manual'; a redirect is reported, never followed.
The outcome contract
Section titled “The outcome 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 }The tests that pin it
Section titled “The tests that pin it”Each marker below is checked by test/docs-gate.test.ts: a renamed or deleted pin fails this page.
- Invariant 1, the google branch through the tool — the wrong providers’ fakes throw, so a wrong selection fails by name.
- Invariant 1, the three Responses APIs — each to
openai, through the tool.
- Invariants 5 and 6, the happy path —
web-search.test.ts: Tavily is not called; exactly one audit record names provider and outcome.
- Invariants 3 and 5, unarmed — the accuracy contract with nothing to fall back to.
- Invariant 3, armed — the key reaches neither the text nor the audit record.
- Invariants 1 and 3 — an
openai-completionsmodel selects nothing; no provider ran.
- Invariant 6, the one deadline — the fallback receives an already-dead signal.
- Invariant 2 —
web-search-support.test.ts: the injected reader is consulted once, and not at all when a key or an auth header was resolved.
- Invariant 4 —
web-search-wire.test.ts: the fixture upstream’s source-count rule called grounded.
- Invariant 7 —
web-fetch.test.ts: one fetch, the target in the text.
Where it is stated
Section titled “Where it is stated”The seam and its fences: seams/web-providers.md. The config knobs and
the secrets file: reference/env-and-config.md. Egress and
secret: glossary.md → Permission. The shape in one pass: the header of
packages/harness/extensions/web-search/index.ts; the deadline and the arming rule: packages/core/src/web-search.ts.