- Re-validate redirect targets through validateFetchUrl before following, so a same-origin Location carrying credentials (or a non-http(s)/over-long URL) cannot bypass the transport hygiene a direct request enforces. - Treat only DROPPED bytes as truncation: a body exactly at maxResponseBytes is no longer falsely flagged truncated (which emitted a spurious footer). - Honor the declared response charset: parse the Content-Type charset and decode with it (rejecting unsupported labels as WEB_UNSUPPORTED_CONTENT_TYPE) instead of always assuming UTF-8 and returning replacement characters. - Catalog the web seam vocabulary in docs/core-data-structures/web.md with type-equiv blocks + manifest entries, per the core-data-structures rule.
120 lines
7.5 KiB
Markdown
120 lines
7.5 KiB
Markdown
# Web Access
|
|
|
|
The web access seam — a [capability seam](../rfc/implemented/architecture/2026-06-24-web-capability-seam.md) that spans **two capabilities** (search and fetch) on one `ctx.web` service, split across packages: interface ([dsh-web](../../packages/web/web), `ctx.web` + the provider registries), implementations ([dsh-web-search-exa](../../packages/web/web-search-exa), [dsh-web-search-perplexity](../../packages/web/web-search-perplexity), [dsh-web-fetch-local](../../packages/web/web-fetch-local)), and consumer ([dsh-tool-web](../../packages/web/tool-web), the `web_search`/`web_fetch` tool schemas). Web is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A search-provider swap does not change how the model asks for a query, and a fetch-implementation swap does not change how the model asks for a URL.
|
|
|
|
Source: [`packages/web/web/src/types.ts`](../../packages/web/web/src/types.ts)
|
|
|
|
## Why one seam for two capabilities
|
|
|
|
Search and fetch share no request schema and no business logic, but they are deliberately one `ctx.web` middle layer: one provider-selection policy owner, one abort/error vocabulary, one product-facing "how this harness reaches the web" config surface. The cost is the parallel `searchX`/`fetchX` method pairs on the service; that parallelism is intentional, not a missed extraction. Providers register **capabilities** (a `WebSearchProvider` or `WebFetchProvider`), not tools; the model-facing names, schemas, prompt guidance, and presentation all live in the single `dsh-tool-web` consumer.
|
|
|
|
## Search request and result
|
|
|
|
The model-facing tool argument is just a `query`; `maxResults` is a consumer-owned bound (`dsh-tool-web`'s `WEB_SEARCH_MAX_RESULTS`, default `8`) passed through the seam and enforced on the way back — if a provider over-returns, the seam truncates `sources[]` and sets `truncated`.
|
|
|
|
```ts type-equiv
|
|
interface WebSearchRequest {
|
|
readonly query: string
|
|
/**
|
|
* Upper bound on returned sources; the seam truncates to it. Omitted = no
|
|
* bound. `dsh-tool-web` always sets it.
|
|
*/
|
|
readonly maxResults?: number
|
|
}
|
|
```
|
|
|
|
```ts type-equiv
|
|
interface WebSearchResult {
|
|
readonly providerId: string
|
|
readonly query: string
|
|
readonly content?: string
|
|
readonly sources: readonly WebSearchSource[]
|
|
readonly truncated: boolean
|
|
}
|
|
```
|
|
|
|
`content` is optional provider-generated answer text (Exa returns none; Perplexity returns a generated answer). `sources[]` is the portable citation surface. A source always has a `url`; `title`/`snippet`/`publishedAt` are optional because not every provider returns them — Perplexity citations may be URL-only, and forcing adapters to invent the rest would make the seam lie. `dsh-tool-web` renders `title ?? hostname(url)`.
|
|
|
|
```ts type-equiv
|
|
interface WebSearchSource {
|
|
readonly url: string
|
|
readonly title?: string
|
|
readonly snippet?: string
|
|
readonly publishedAt?: string
|
|
}
|
|
```
|
|
|
|
## Fetch request and result
|
|
|
|
```ts type-equiv
|
|
interface WebFetchRequest {
|
|
readonly url: string
|
|
readonly timeoutMs?: number
|
|
}
|
|
```
|
|
|
|
HTTP status is part of the fetched resource state, not automatically a failure: a successful network fetch of a `404`/`500` returns a `WebFetchResult` with the status code and a bounded decoded body. `url` is the final URL after allowed redirects. `WebError` is reserved for failures to safely retrieve or represent the resource.
|
|
|
|
```ts type-equiv
|
|
interface WebFetchResult {
|
|
readonly providerId: string
|
|
readonly url: string
|
|
readonly statusCode: number
|
|
readonly body: WebFetchBody
|
|
readonly truncated: boolean
|
|
}
|
|
```
|
|
|
|
`WebFetchBody` is a **closed** discriminated union owned by `dsh-web` (not a merge-extensible map): the provider decodes the kind and `dsh-tool-web` renders it, so a new kind is a coordinated change across known packages, not a plugin extension. Consumers `switch` on `kind` ending in `default: assertNever(...)`, so adding a kind breaks compilation at every consumer until handled. Each arm stays its own object literal even where fields coincide today, leaving room for arm-specific fields later (a future `pdf` body's `pageCount`).
|
|
|
|
```ts type-equiv
|
|
type WebFetchBody =
|
|
| { readonly kind: 'html'; readonly content: string }
|
|
| { readonly kind: 'text'; readonly content: string }
|
|
```
|
|
|
|
## Provider and capability status
|
|
|
|
A provider's `status()` is a cheap LOCAL check (credential presence, parseable config) and **must not make network calls**. It is an input to selection, not a health system.
|
|
|
|
```ts type-equiv
|
|
type WebProviderStatus =
|
|
| { readonly available: true }
|
|
| { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' }
|
|
```
|
|
|
|
The service aggregates provider status into a `WebCapabilityStatus`: whether the capability has a selected usable provider, or the broad category in which selection fails. It carries the winning `providerId` on the available branch but NOT the per-reason payload (the missing id, the ambiguous set) — that branchable detail lives in the thrown `WebError`, the surface callers route on, so the same fact never gets two homes that can disagree.
|
|
|
|
```ts type-equiv
|
|
type WebCapabilityStatus =
|
|
| { readonly available: true; readonly providerId: string }
|
|
| { readonly available: false; readonly reason: 'none' | 'configured-missing' | 'configured-unavailable' | 'ambiguous' }
|
|
```
|
|
|
|
Selection never depends on registration, config, or HMR order: a capability has an explicit provider id (config `searchProvider`/`fetchProvider`, or the matching env var feeding the same field), or auto-selects when exactly one usable provider is registered; multiple usable providers with no configured id is `ambiguous`, not first-wins.
|
|
|
|
## Errors
|
|
|
|
`WebError extends HarnessError` ([core.md](core.md) error taxonomy) with a stable `WebErrorCode`. `WEB_DUPLICATE_PROVIDER` is a registration-time programming error (the analogue of `LlmService`'s `DUPLICATE_ADAPTER`); the `WEB_PROVIDER_*` selection codes and the fetch transport codes are execution outcomes. `WEB_PROVIDER_ERROR` is the catch-all for a provider's own failure surfaced through the seam, including network/transport failure (DNS, connection refused, TLS).
|
|
|
|
```ts type-equiv
|
|
type WebErrorCode =
|
|
| 'WEB_PROVIDER_UNAVAILABLE'
|
|
| 'WEB_PROVIDER_CONFIGURED_MISSING'
|
|
| 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE'
|
|
| 'WEB_PROVIDER_AMBIGUOUS'
|
|
| 'WEB_DUPLICATE_PROVIDER'
|
|
| 'WEB_INVALID_URL'
|
|
| 'WEB_BLOCKED_URL'
|
|
| 'WEB_REDIRECT_BLOCKED'
|
|
| 'WEB_FETCH_TOO_LARGE'
|
|
| 'WEB_FETCH_TIMEOUT'
|
|
| 'WEB_ABORTED'
|
|
| 'WEB_UNSUPPORTED_CONTENT_TYPE'
|
|
| 'WEB_PROVIDER_ERROR'
|
|
```
|
|
|
|
## The service
|
|
|
|
`WebService` (`ctx.web`, defined in [`packages/web/web/src/index.ts`](../../packages/web/web/src/index.ts)) is a provider registry plus a provider-selecting execution surface, close to `LlmService`'s shape: `registerSearchProvider`/`registerFetchProvider` (duplicate ids throw `WEB_DUPLICATE_PROVIDER`, return disposers, emit `web/providers-change`), `searchStatus`/`fetchStatus` (derived, never stored), and `search`/`fetch` (resolve the provider at call time, throw a structured `WebError` when the capability cannot run). Providers issue requests with the platform-native `fetch` (Node 24), mirroring `dsh-llm-deepseek`; the `dsh-web-fetch-local` provider owns safe retrieval (http/https-only, credential rejection, byte/char/timeout/redirect caps, same-origin-only redirects with per-hop re-validation, charset decoding) while `dsh-tool-web` owns presentation (HTML→markdown). SSRF / private-network blocking is deferred (see the RFC) — until it lands, `web_fetch` must not be enabled where it can reach sensitive internal targets.
|