The web access seam
packages/web/web defines ctx.web, the WebRuntime service — one seam, two registries. Unlike the single-implementation shell/fs seams, ctx.web is provider-selecting: search and fetch providers each register under a stable id, and the seam resolves which provider to use at execution time. All web packages live under packages/web/.
tool-web (web_search / web_fetch) (CONSUMER)
│
┌────────────────▼─────────────────┐
│ Service Definition: ctx.web │ @deepseek-ai/dsh-web
│ WebRuntime — 2 registries │
└──────────┬──────────────────┬────┘
search │ │ fetch
┌───────────┼───┐ ┌─────┴──────────────┐
▼ ▼ ▼ ▼ ▼
web-search- web- web-search- web-fetch-http
deepseek exa perplexity (id 'http')
(deepseek-official)Package versions
| Package | Provider id | Role |
|---|---|---|
@deepseek-ai/dsh-web | — | Service Definition (WebRuntime, ctx.web, WebError) |
@deepseek-ai/dsh-web-fetch-http | http | Fetch provider: anonymous public HTTP(S) |
@deepseek-ai/dsh-web-search-deepseek | deepseek-official | Search provider: DeepSeek Anthropic-compatible API |
@deepseek-ai/dsh-web-search-exa | exa | Search provider: Exa |
@deepseek-ai/dsh-web-search-perplexity | perplexity | Search provider: Perplexity |
@deepseek-ai/dsh-tool-web | — | Consumer: web_search / web_fetch model tools |
Provider selection
WebRuntime config pins which provider wins per capability: searchProvider / fetchProvider, both optional. Operational environment variables feed the same fields — $DSH_WEB_SEARCH_PROVIDER / $DSH_WEB_FETCH_PROVIDER are equivalent to the config keys, not a hidden priority chain. resolveProvider is invoked at execution time and is never order-dependent:
- Configured id registered +
available()→ that provider. - Configured id not registered →
WEB_PROVIDER_CONFIGURED_MISSING. - Configured id registered but unavailable →
WEB_PROVIDER_CONFIGURED_UNAVAILABLE. - No id, exactly one usable provider → that provider.
- No id, multiple usable →
WEB_PROVIDER_AMBIGUOUS. - No id, no usable →
WEB_PROVIDER_UNAVAILABLE.
available() is a cheap local check that must not make network calls (e.g. Exa checks a non-empty API key + valid base URL). The WebRuntime seam also enforces maxResults on search results (truncating sources[] and setting truncated).
The WebRuntime interface
WebRuntime.search(request, signal?) and WebRuntime.fetch(request, signal?) are the two execution verbs, resolving a provider that implements WebSearchProvider / WebFetchProvider — each { id, available(), search|fetch(request, signal) }. The normalized shapes:
// Search
interface WebSearchRequest { query: string; maxResults?: number }
interface WebSearchResult {
content?: string // provider-generated answer text (Perplexity only)
sources: readonly WebSearchSource[] // { url, title?, snippet?, publishedAt? }
truncated: boolean
}
// Fetch
interface WebFetchRequest { url: string } // no timeout/format/prompt knobs by design
interface WebFetchResult {
url: string // final URL after redirects
statusCode: number // a non-2xx is a result, not a throw
body: WebFetchBody // { kind: 'html'|'text', content } — closed union
truncated: boolean
}Note two deliberate contracts: a non-2xx fetch is a result, not an error (the status code is part of the resource state — WebError is reserved for failures to safely retrieve or represent); and WebFetchBody is a closed discriminated union owned by dsh-web, so adding a kind is a coordinated change across known packages, and consumers switch on kind ending in assertNever.
The model-facing tools
@deepseek-ai/dsh-tool-web registers web_search and web_fetch, both software-enabled via config (search/fetch bools, both default true). Enablement controls registration; an enabled tool stays visible when its provider is unavailable and fails with a structured WebError at execution time.
Tool config: searchMaxResults (default WEB_SEARCH_MAX_RESULTS = 8, the cap also sent as every seam request's maxResults), fetchTimeoutMs/searchTimeoutMs (default 30_000, attached as ToolDefinition.timeoutMs for the tool-call timeout policy), and fetchMaxOutputChars (default 200_000, the cap on decoded fetch output and per-source characters).
web_search— one parameter:query. Returns normalized sources; aWebErrorcode is exposed in structured error metadata. The output is rendered as fenced source links + snippets.web_fetch— one parameter:url. Returns the final URL, status code, and decoded body (htmlvia the turndown GFMD Markdown conversion, ortext), bounded by the body cap, with atruncatedflag.
How results are rendered for the model
formatSearchOutput (packages/web/tool-web/src/search.ts) turns a normalized WebSearchResult into the markdown the model sees, in a stable order:
<content?> # provider answer text first, if present (Perplexity)
Sources:
- [label](url) — snippet (publishedAt)
… # one bullet per source, capped by searchMaxResults
(Showing the first N sources. Refine the query for more.) # only when truncated
Cite the relevant URLs above as markdown links in your answer.A URL-only source renders its title if present, else the hostname (sourceLabel). The trailing "Cite the relevant URLs above…" line keeps provenance in the model's answer.
web_fetch converts HTML to Markdown through the shared turndown converter (fetch.ts), classifies the body kind, and formatFetchOutput renders URL + status code + body, capped at fetchMaxOutputChars and reporting truncated. The tool's presentation layer re-derives the { url, statusCode, truncated } envelope on replay from presentationMeta, so the completed view needs no re-fetch.
The fetch provider: web-fetch-http
web-fetch-http registers an anonymous public HTTP(S) WebFetchProvider (id: 'http', LOCAL_FETCH_PROVIDER_ID). It is a function/namespace plugin (not a default-export service) that registers into the seam's fetch registry. Config (all defaulted):
| Key | Default | Meaning |
|---|---|---|
maxUrlLength | 2048 | max accepted request URL length |
maxResponseBytes | 5_000_000 | max response body bytes |
maxBodyChars | 100_000 | max decoded body characters |
timeoutMs | 30_000 | default fetch timeout |
maxRedirects | 5 | same-origin redirect hops to follow |
userAgent | deepseek-harness/0.0.1 (+https://github.com/deepseek-ai) | explicit product agent, never a browser disguise |
The transport hygiene policy lives in packages/web/web-fetch-http/src/policy.ts, the pure network-free half (validateFetchUrl, isSameOrigin, content-type classification):
- http(s) only; embedded credentials in URLs are rejected (
WEB_BLOCKED_URL); bounded length (WEB_INVALID_URL). - Redirects must stay same-origin — a cross-origin hop is refused so each new origin requires a fresh tool call (and thus a fresh provider/permission decision).
- Content-Type classification:
text/html/application/xhtml+xml→html; othertext/*plus a few structured text types →text; binary/unsupported → not decodable. - SSRF / private-network blocking is deferred (not yet enforced) — noted in the package Agent Note.
The search providers
Each provider lives in its own package, registers under a stable id, and normalizes results into WebSearchSource[] (a source always has a URL; title/snippet/publishedAt are optional because not every provider returns them — forcing an adapter to invent them would make the seam lie). Provider-generated answer text (content) is only produced by Perplexity.
DeepSeek (web-search-deepseek, id deepseek-official)
Calls DeepSeek's Anthropic-compatible Messages API — not the chat-completions base the LLM layer uses, so it does not reuse $DEEPSEEK_BASE_URL; only the API key is shared. Defaults: base https://api.deepseek.com/anthropic/v1 (then append /messages), model deepseek-v4-flash, API version 2023-06-01, max_tokens 4096, max_uses 5. It uses the Anthropic web_search_20250305 server tool in a Messages request. Config: apiKey (literal key), apiKeyEnv (env var name, default DEEPSEEK_API_KEY, also resolvable via a CredentialRef), baseURL, model, maxTokens, maxUses, apiVersion. A secret-free auxiliary request is recorded on the session event web/deepseek-search-llm-request before dispatch.
Exa (web-search-exa, id exa)
Calls Exa's POST /search. Defaults: base https://api.exa.ai, searchType 'auto', highlightsPerResult 1. Config: apiKey (env default $EXA_API_KEY), baseURL, searchType (auto|keyword|neural), numResults, highlightsPerResult. A result with no highlight is dropped — the seam has no other field to derive a snippet from, and inventing one would lie. Exa returns no generated answer (content omitted).
Perplexity (web-search-perplexity, id perplexity)
Calls Perplexity's OpenAI-compatible /chat/completions endpoint and returns a generated answer, which becomes content; its citations become sources. Config: apiKey (env default $PERPLEXITY_API_KEY), baseURL, model.
Rate limiting & retries
There is no seam-level rate limiter or automatic retry in the web packages — the seam is deliberately thin. Provider HTTP handling sets explicit timeouts (web-fetch-http via @deepseek-ai/dsh-timeout's deadline(...), tagged 'WEB_FETCH_TIMEOUT'), and the tool layer supplies cooperative per-call timeout budgets (fetchTimeoutMs/searchTimeoutMs) enforced by the tool-call timeout policy. Retry/pacing across turns is a consumer/agent-loop concern, and the same-origin redirect rule is the safety backstop for infinite fetch loops. Errors are typed WebError open-string codes with a chained cause, exposed as structured error metadata on tool results.
Further reading
- Shell & Terminal — the sibling executor seam framing how
tool-web's timeouts attach viaToolDefinition.timeoutMs. - Filesystem Tools & Policies — the
ctx.fsseam sharing the same provider/consumer split. - Code Runtime — where the LLM-facing "tools" bindings a program can call are defined.
- API Proxy (apiproxy) — how credential/network concerns are dispatch for host APIs.
packages/web/web/src/index.ts— theWebRuntimedefinition and selection rules.packages/web/web/src/types.ts— the normalized request/result/source shapes.