Răsfoiți Sursa

Merge branch 'master' into worktree/provider-routed-llm-adapters

Yichen Jiang 1 lună în urmă
părinte
comite
3c7f4d4162
35 a modificat fișierele cu 250 adăugiri și 354 ștergeri
  1. 3 5
      docs/config-catalog.md
  2. 4 4
      docs/cordis-catalog/services.md
  3. 1 1
      docs/core-data-structures/core.md
  4. 2 12
      docs/core-data-structures/web.md
  5. 1 1
      docs/rfc/INDEX.md
  6. 16 31
      docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md
  7. 1 1
      docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md
  8. 6 11
      docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md
  9. 7 15
      packages/cordis/tool-cordis/src/api-catalog.ts
  10. 1 1
      packages/web/tool-web/README.md
  11. 1 1
      packages/web/tool-web/src/fetch.ts
  12. 1 1
      packages/web/tool-web/src/search.ts
  13. 13 6
      packages/web/tool-web/tests/integration.spec.ts
  14. 34 34
      packages/web/tool-web/tests/tool-web.spec.ts
  15. 2 3
      packages/web/web-fetch-local/README.md
  16. 12 7
      packages/web/web-fetch-local/src/index.ts
  17. 7 11
      packages/web/web-fetch-local/src/provider.ts
  18. 12 11
      packages/web/web-fetch-local/tests/fetch-local.spec.ts
  19. 2 2
      packages/web/web-search-deepseek/README.md
  20. 11 13
      packages/web/web-search-deepseek/src/provider.ts
  21. 0 1
      packages/web/web-search-deepseek/tests/deepseek.e2e.ts
  22. 18 25
      packages/web/web-search-deepseek/tests/deepseek.spec.ts
  23. 2 2
      packages/web/web-search-exa/README.md
  24. 11 14
      packages/web/web-search-exa/src/provider.ts
  25. 0 1
      packages/web/web-search-exa/tests/exa.e2e.ts
  26. 11 18
      packages/web/web-search-exa/tests/exa.spec.ts
  27. 2 2
      packages/web/web-search-perplexity/README.md
  28. 9 14
      packages/web/web-search-perplexity/src/provider.ts
  29. 0 1
      packages/web/web-search-perplexity/tests/perplexity.e2e.ts
  30. 15 21
      packages/web/web-search-perplexity/tests/perplexity.spec.ts
  31. 5 5
      packages/web/web/README.md
  32. 10 14
      packages/web/web/src/index.ts
  33. 10 42
      packages/web/web/src/types.ts
  34. 20 22
      packages/web/web/tests/web.spec.ts
  35. 0 1
      scripts/type-equiv.manifest.json

+ 3 - 5
docs/config-catalog.md

@@ -1086,7 +1086,7 @@ export interface WebServiceConfig {
 }
 ```
 
-Source: [`packages/web/web/src/index.ts:59`](../packages/web/web/src/index.ts)
+Source: [`packages/web/web/src/index.ts:55`](../packages/web/web/src/index.ts)
 
 ## `@deepseek-ai/dsh-web-fetch-local`
 
@@ -1101,10 +1101,8 @@ export interface Config {
   maxResponseBytes?: number
   /** Maximum decoded body length in characters. */
   maxBodyChars?: number
-  /** Default fetch timeout in milliseconds. */
+  /** Default fetch timeout in milliseconds, within Node's timer range. */
   timeoutMs?: number
-  /** Upper bound for a per-request timeout override. */
-  maxTimeoutMs?: number
   /** Maximum number of same-origin redirect hops to follow. */
   maxRedirects?: number
   /** `User-Agent` header sent on every request. */
@@ -1112,7 +1110,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/web/web-fetch-local/src/index.ts:34`](../packages/web/web-fetch-local/src/index.ts)
+Source: [`packages/web/web-fetch-local/src/index.ts:36`](../packages/web/web-fetch-local/src/index.ts)
 
 ## `@deepseek-ai/dsh-web-search-deepseek`
 

+ 4 - 4
docs/cordis-catalog/services.md

@@ -276,7 +276,7 @@ The web access service. Registered as `ctx.web` (one instance per context).
 
 Selection semantics (resolved at execution time, never order-dependent):
 
-- A configured id that is registered and `status().available` → that provider.
+- A configured id that is registered and `available()` → that provider.
 - A configured id not registered → `WEB_PROVIDER_CONFIGURED_MISSING`.
 - A configured id registered but unavailable → `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`.
 - No id configured, exactly one registered usable provider → that provider.
@@ -286,11 +286,11 @@ Selection semantics (resolved at execution time, never order-dependent):
 ```ts cordis-catalog
 registerSearchProvider(provider: WebSearchProvider): () => void
 registerFetchProvider(provider: WebFetchProvider): () => void
-async search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult>
-async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult>
+async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
+async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
 ```
 
-Source: [`packages/web/web/src/index.ts:78`](../../packages/web/web/src/index.ts)
+Source: [`packages/web/web/src/index.ts:74`](../../packages/web/web/src/index.ts)
 
 ## `ctx.workflows` — `WorkflowService` (abstract seam)
 

+ 1 - 1
docs/core-data-structures/core.md

@@ -31,7 +31,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
 | [skills.md](skills.md) | the skill service: discovery priority, `SkillSummary`/`SkillDefinition`, session-prefix catalog, model-facing `skill` loading |
 | [compaction.md](compaction.md) | the compaction seam: the `compact/*` session events, `CompactionResult`, the `CompactService` interface |
 | [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split |
-| [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider/capability status, `WebError` |
+| [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider availability, `WebError` |
 | [workflow.md](workflow.md) | the workflow seam: `WorkflowStartRequest`, `WorkflowMeta`, `WorkflowRun`/`Result`, the `workflow/*` event payloads, `WorkflowError` fatality |
 
 > Type definitions on this page are pasted **verbatim** from source and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Inline JSDoc is omitted for readability; follow the source link for the full contracts.

+ 2 - 12
docs/core-data-structures/web.md

@@ -25,8 +25,6 @@ interface WebSearchRequest {
 
 ```ts type-equiv
 interface WebSearchResult {
-  readonly providerId: string
-  readonly query: string
   readonly content?: string
   readonly sources: readonly WebSearchSource[]
   readonly truncated: boolean
@@ -49,7 +47,6 @@ interface WebSearchSource {
 ```ts type-equiv
 interface WebFetchRequest {
   readonly url: string
-  readonly timeoutMs?: number
 }
 ```
 
@@ -57,7 +54,6 @@ HTTP status is part of the fetched resource state, not automatically a failure:
 
 ```ts type-equiv
 interface WebFetchResult {
-  readonly providerId: string
   readonly url: string
   readonly statusCode: number
   readonly body: WebFetchBody
@@ -73,15 +69,9 @@ type WebFetchBody =
   | { readonly kind: 'text'; readonly content: string }
 ```
 
-## Provider 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 execution-time selection, not a health system: `search()`/`fetch()` read it to pick a usable provider, and a selection failure surfaces as the structured `WebError` the caller routes on — which carries the branchable detail (the missing id, the ambiguous candidate set) in its code and message.
+## Provider availability
 
-```ts type-equiv
-type WebProviderStatus =
-  | { readonly available: true }
-  | { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' }
-```
+A provider's `available(): boolean` is a cheap LOCAL check (credential presence, parseable config) and **must not make network calls**. It is an input to execution-time selection, not a health system: `search()`/`fetch()` read it to pick a usable provider, and a selection failure surfaces as the structured `WebError` the caller routes on — which carries the branchable detail (the missing id or ambiguous candidate set) in its code and message.
 
 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 `WEB_PROVIDER_AMBIGUOUS`, not first-wins.
 

+ 1 - 1
docs/rfc/INDEX.md

@@ -20,7 +20,6 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
 |---|---|
 | [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 |
 | [Prune dead public and result surface](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 |
-| [Prune unused web seam fields](proposed/simplification/2026-07-12-prune-unused-web-seam-fields.md) | 2026-07-12 |
 | [Simplify session-log representation](proposed/simplification/2026-07-12-simplify-session-log-representation.md) | 2026-07-12 |
 
 ### Architecture
@@ -103,6 +102,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
 | [Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics](implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md) | 2026-07-04 |
 | [Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback](implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md) | 2026-07-04 |
 | [Drop unconsumed skill provider events](implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md) | 2026-07-12 |
+| [Prune unused web seam fields](implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md) | 2026-07-12 |
 
 ### Architecture
 

+ 16 - 31
docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md

@@ -64,7 +64,7 @@ flowchart LR
   toolWeb -->|ctx.tools.register| webFetch["tool: web_fetch"]
 ```
 
-`@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support. It declares `ctx.web`, provider interfaces, request/result types, the provider status type, and error codes. It does not import tool, agent, session, LLM, or provider packages.
+`@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support. It declares `ctx.web`, provider interfaces, request/result types, the provider availability contract, and error codes. It does not import tool, agent, session, LLM, or provider packages.
 
 Provider packages depend only on `dsh-web` and Cordis. They own credentials, endpoints, wire mapping, parsing, and `WebError` translation, using platform `fetch`. Each provider injects the shared service and registers a backend; only `dsh-web` owns the `ctx.web` key. Provider-private protocol shapes do not create dependencies on `ctx.llm` or a Cordis HTTP service.
 
@@ -77,52 +77,42 @@ Provider packages depend only on `dsh-web` and Cordis. They own credentials, end
 ```ts
 interface WebSearchProvider {
   readonly id: string
-  status(): WebProviderStatus
-  search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult>
+  available(): boolean
+  search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
 }
 
 interface WebFetchProvider {
   readonly id: string
-  status(): WebProviderStatus
-  fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult>
+  available(): boolean
+  fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
 }
 
 interface WebService {
   registerSearchProvider(provider: WebSearchProvider): () => void
   registerFetchProvider(provider: WebFetchProvider): () => void
 
-  search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult>
-  fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult>
-}
-
-interface WebExecContext {
-  readonly signal?: AbortSignal
+  search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
+  fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
 }
 ```
 
-`WebExecContext` is execution control, not business input. It carries only `signal`, so `tool-web` propagates turn cancellation, tool timeout, and agent disposal into provider network requests, SSE readers, and expensive decoding. It does not pass `ToolExecution` through the seam — that would make `dsh-web` depend on `dsh-tools`.
+The optional signal is execution control, not business input: `tool-web` passes `exec.signal` directly so turn cancellation, tool timeout, and agent disposal reach provider network requests, stream readers, and expensive decoding. The seam does not pass `ToolExecution` through — that would make `dsh-web` depend on `dsh-tools`.
 
 Provider ids are stable strings and unique within their capability kind. Registering a duplicate search provider id or duplicate fetch provider id fails rather than silently replacing the old provider. Provider registration returns a disposer and follows the existing `ctx.tools.register()` / `ctx.systemPrompt.section()` pattern: the mutation is wrapped in `ctx.effect()` so the registration is torn down with the contributing fiber.
 
-## Provider status and selection
-
-Provider status and capability selection are separate concepts, but both stay minimal. A provider reports only whether that concrete implementation is usable by cheap local checks such as credential presence or parseable endpoint config. A provider `status()` must not make network calls.
+## Provider availability and selection
 
-`LlmService` has no status type at all: availability is expressed as registry membership plus a resolution-time throw. `ctx.web` follows the same discipline. The seam exposes no aggregated capability-status query — `search()` / `fetch()` derive the selection on each call from the configured provider id, the registered providers, and each provider's cheap local `status()`, and a selection failure is the structured `WebError` thrown at execution time, whose code answers "in which broad category does this capability fail" and whose message answers "exactly which provider/ids/reason." A caller that needs to know whether a capability can run executes and routes that error; nothing is stored as mutable service state.
+Provider availability and capability selection are separate concepts, but both stay minimal. A provider reports only whether that concrete implementation is usable by cheap local checks such as credential presence or parseable endpoint config. A provider `available()` must not make network calls.
 
-`WebProviderStatus` is an input to selection, not a health system. `tool-web` never calls a provider's `status()` directly — its only path into the seam is `search()` / `fetch()` — so selection policy has one owner.
+`LlmService` has no status type at all: availability is expressed as registry membership plus a resolution-time throw. `ctx.web` follows the same discipline. The seam exposes no aggregated capability-status query — `search()` / `fetch()` derive the selection on each call from the configured provider id, the registered providers, and each provider's cheap local `available()` boolean, and a selection failure is the structured `WebError` thrown at execution time. A caller that needs to know whether a capability can run executes and routes that error; nothing is stored as mutable service state.
 
-```ts
-type WebProviderStatus =
-  | { readonly available: true }
-  | { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' }
-```
+The boolean is an input to selection, not a health system. `tool-web` never calls a provider's `available()` directly — its only path into the seam is `search()` / `fetch()` — so selection policy has one owner.
 
 Selection must not depend on registration order. Cordis load order, config ordering, and HMR timing are not product semantics.
 
 | Situation | Execution behavior |
 |---|---|
-| A configured provider id is registered and `status().available === true` | runs that provider |
+| A configured provider id is registered and `available() === true` | runs that provider |
 | A configured provider id is not registered | fails with `WEB_PROVIDER_CONFIGURED_MISSING` |
 | A configured provider id is registered but unavailable | fails with `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` |
 | No provider id is configured and exactly one provider for that kind is registered and available | runs that single provider |
@@ -184,8 +174,6 @@ interface WebSearchRequest {
 }
 
 interface WebSearchResult {
-  readonly providerId: string
-  readonly query: string
   readonly content?: string
   readonly sources: readonly WebSearchSource[]
   readonly truncated: boolean
@@ -212,20 +200,17 @@ The `web_fetch` implementation is an anonymous public HTTP(S) fetch provider, `l
 The seam request stays smaller than OpenCode's model-facing tool:
 
 - `url`: required HTTP(S) URL.
-- `timeoutMs`: optional positive number capped by the provider.
 
-The seam request deliberately does not include `format`, `prompt`, or provider-specific extraction controls. `format` is a presentation decision over a fetched resource; `prompt` is a higher-level LLM summarization instruction; extraction APIs such as Firecrawl, Exa, Tavily, or Parallel may not expose a concrete HTTP response. If the product later needs provider-backed page extraction, that is a separate `web_extract` capability or a deliberate widening of this seam — extract semantics are never smuggled into `web_fetch` by making every HTTP field optional.
+The seam request deliberately does not include a per-call timeout, `format`, `prompt`, or provider-specific extraction controls. Cancellation is the direct optional execution signal, while the fetch provider owns one deployment-configured timeout backstop. `format` is a presentation decision over a fetched resource; `prompt` is a higher-level LLM summarization instruction; extraction APIs such as Firecrawl, Exa, Tavily, or Parallel may not expose a concrete HTTP response. If the product later needs provider-backed page extraction, that is a separate `web_extract` capability or a deliberate widening of this seam — extract semantics are never smuggled into `web_fetch` by making every HTTP field optional.
 
 HTTP status is part of the fetched resource state, not automatically a tool failure. A successful network fetch of a `404` or `500` response returns `WebFetchResult` with the status code and a bounded decoded body when the content type is supported. `WebError` is for failures to safely retrieve or represent the resource: invalid or blocked URL, redirect policy violation, timeout, abort, response too large, unsupported content type, provider failure, or network failure.
 
 ```ts
 interface WebFetchRequest {
   readonly url: string
-  readonly timeoutMs?: number
 }
 
 interface WebFetchResult {
-  readonly providerId: string
   readonly url: string
   readonly statusCode: number
   readonly body: WebFetchBody
@@ -257,11 +242,11 @@ SSRF / private-network protection (blocking private, loopback, link-local, multi
 
 `dsh-tool-web` owns two `ToolDefinition`s: `web_search` and `web_fetch`. It owns model-facing JSON schemas, snake_case argument names, prompt sections, result rendering to `ContentBlock[]`, `presentCall`, and `presentResult`.
 
-`dsh-tool-web` must not enumerate providers or call provider `status()` directly. Its only path into the seam is `ctx.web.search()` / `ctx.web.fetch()`. That keeps provider selection in one layer; otherwise the tool package could decide one provider is usable while execution resolves a different state.
+`dsh-tool-web` must not enumerate providers or call provider `available()` directly. Its only path into the seam is `ctx.web.search()` / `ctx.web.fetch()`. That keeps provider selection in one layer; otherwise the tool package could decide one provider is usable while execution resolves a different state.
 
 Tool registration is a minimal stable sync: on plugin startup the `dsh-tool-web` `Config` (`search?: boolean`, `fetch?: boolean`, both default `true`) enables or disables each web tool; an enabled tool is registered with a fiber-scoped disposer via the effect-based registry; neither tool is disposed merely because its selected provider is missing, unusable, or ambiguous; disposing the `tool-web` fiber tears down its registrations automatically.
 
-Provider status changes affect execution results and diagnostics, not whether the model-facing schema exists. If a product wants no web tools at all, it disables `dsh-tool-web` or the individual web tool in config; if it wants web tools but the backend is misconfigured, the model sees a structured tool error at execution time.
+Provider availability changes affect execution results and diagnostics, not whether the model-facing schema exists. If a product wants no web tools at all, it disables `dsh-tool-web` or the individual web tool in config; if it wants web tools but the backend is misconfigured, the model sees a structured tool error at execution time.
 
 The prompt guidance explains the semantic split — `web_search` for discovery and current information, `web_fetch` when the model needs the content of a specific URL — and the prompt and tool result tell the model to cite relevant URLs with markdown links.
 

+ 1 - 1
docs/rfc/implemented/architecture/2026-07-07-tool-call-timeout-policy.md

@@ -74,7 +74,7 @@ No new session event is needed for reconstructability: `TOOL_TIMEOUT` is the fin
 
 `web_fetch` and `web_search` are migrated. `dsh-tool-web` keeps ownership of their model-facing schemas, and those schemas expose no timeout knob: `web_fetch` dropped its `timeout_ms` parameter to match the reference-agent shape, and `web_search` stays query-only. The tool bodies do not import `@deepseek-ai/dsh-timeout`; they forward `exec.signal` to `ctx.web`.
 
-`dsh-web-fetch-local` keeps a provider-level timeout (`timeoutMs`/`maxTimeoutMs`) as a large resource backstop for direct `ctx.web.fetch()` callers and misconfigured deployments; it owns no model-facing timeout. When a `TOOL_TIMEOUT` signal reaches the fetch provider first, provider-scoped classification treats it as upstream `WEB_ABORTED`, and the outer `tools/execute` wrapper replaces the final tool result with `TOOL_TIMEOUT`. A shipped web-tool deployment configures the provider backstop above the `timeout-policy` budget so the tool-call policy normally wins for model calls.
+`dsh-web-fetch-local` keeps one configured provider-level `timeoutMs` as a large resource backstop for direct `ctx.web.fetch()` callers and misconfigured deployments; it owns no model-facing timeout. When a `TOOL_TIMEOUT` signal reaches the fetch provider first, provider-scoped classification treats it as upstream `WEB_ABORTED`, and the outer `tools/execute` wrapper replaces the final tool result with `TOOL_TIMEOUT`. A shipped web-tool deployment configures the provider backstop above the `timeout-policy` budget so the tool-call policy normally wins for model calls.
 
 `bash` stays on the current backend timeout path. `dsh-tool-bash` continues to expose `timeoutMs` and `run_in_background`; `dsh-bash-local` continues to use `@deepseek-ai/dsh-timeout` for `BASH_TIMEOUT`; hook bridges continue to call `runHook()` and pass `timeoutMs` through `ctx.bash`. This keeps foreground/background/hook behavior stable.
 

+ 6 - 11
docs/rfc/proposed/simplification/2026-07-12-prune-unused-web-seam-fields.md → docs/rfc/implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md

@@ -1,6 +1,6 @@
 # RFC: Prune unused web seam fields
 
-Status: proposed
+Status: implemented
 
 ## Problem
 
@@ -8,23 +8,18 @@ The web capability carries request/result/status values that every shipped imple
 
 `WebFetchRequest.timeoutMs` is likewise never set by a production caller. `tool-web` supplies only the URL, uses the tool definition's timeout plus `exec.signal` for the caller deadline, and relies on the local provider's configured default as a backstop. The unused per-request override forces `web-fetch-local` to expose `maxTimeoutMs`, clamp two timeout sources, and document/test precedence no product path can select. `WebExecContext` is another one-field wrapper: every caller allocates `{ signal }` and every provider immediately unwraps `exec?.signal`; no second execution-control field exists.
 
-## Proposal
+## Decision
 
-Remove the search/fetch `providerId` result echoes and search `query` echo; callers already own the request and provider selection. Shrink provider status to availability alone, preferably a boolean-returning method if that produces the clearest seam. Remove per-request fetch timeout, `maxTimeoutMs`, and their clamp/validation branches while retaining the provider's configurable default timeout and tool-level deadline. Replace `WebExecContext` with a direct optional `AbortSignal` parameter.
+The web seam omits the search/fetch `providerId` result echoes and search `query` echo; callers already own the request and provider selection. Providers expose availability as a boolean-returning method. Fetch requests have no per-request timeout or `maxTimeoutMs` clamp; the local provider retains its configurable default timeout and the tool retains its own deadline. Provider methods receive a direct optional `AbortSignal` instead of a one-field `WebExecContext` wrapper.
 
-Update all web implementations, the model-facing tool, package READMEs/JSDoc, type-equivalence records, and tests. Keep the interface/implementation/consumer package split, provider selection, source citations, final-URL/status data, truncation reporting, and all safety limits.
+All web implementations and the model-facing tool use the smaller contract. The interface/implementation/consumer package split, provider selection, source citations, final-URL/status data, truncation reporting, and safety limits remain.
 
 ## Alternatives considered
 
 **Keep self-describing results, per-request deadlines, and an extensible execution-context object.** Result echoes can help generic telemetry, a request timeout can help trusted programmatic callers, and the wrapper leaves room for future controls. No such consumer/second field exists; carrying duplicate identity, a second deadline policy, and wrap/unwrap plumbing through every provider makes the current contract harder to implement and explain. If telemetry or per-call budget control arrives, it should define which deadline wins, where provider identity is observed, and whether multiple controls justify a context object.
 
-## Acceptance criteria
+## Consequences
 
-- Every retained web request/result/status field has a production reader or is required to execute the provider request.
-- Tool-visible search/fetch output, provider fallback, abort behavior, configured timeout backstop, truncation, and citations remain covered.
-- No `maxTimeoutMs`, request-timeout precedence branch, or one-field execution-context wrapper remains.
-- Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass.
-
-## Risks
+Every retained web request/result field is consumed by production code or required to execute the provider request. Tool-visible search/fetch output, provider fallback, abort behavior, the configured timeout backstop, truncation, and citations remain covered without a request-timeout precedence branch or execution-context wrapper.
 
 Pre-release programmatic callers lose result provenance echoes and per-request fetch deadlines. The provider still has a deployment-configurable timeout and respects cancellation, so the simplification removes configurability rather than a safety bound.

+ 7 - 15
packages/cordis/tool-cordis/src/api-catalog.ts

@@ -241,8 +241,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
     methods: [
       'registerSearchProvider(provider: WebSearchProvider): () => void',
       'registerFetchProvider(provider: WebFetchProvider): () => void',
-      'async search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult>',
-      'async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult>',
+      'async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>',
+      'async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>',
     ],
   },
   {
@@ -1046,33 +1046,25 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'UserInteractionProvider',
     declaration: 'export interface UserInteractionProvider {\n    ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>;\n}',
   },
-  {
-    name: 'WebExecContext',
-    declaration: 'export interface WebExecContext {\n    readonly signal?: AbortSignal;\n}',
-  },
   {
     name: 'WebFetchBody',
     declaration: 'export type WebFetchBody = {\n    readonly kind: \'html\';\n    readonly content: string;\n} | {\n    readonly kind: \'text\';\n    readonly content: string;\n};',
   },
   {
     name: 'WebFetchProvider',
-    declaration: 'export interface WebFetchProvider {\n    readonly id: string;\n    status(): WebProviderStatus;\n    fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult>;\n}',
+    declaration: 'export interface WebFetchProvider {\n    readonly id: string;\n    available(): boolean;\n    fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>;\n}',
   },
   {
     name: 'WebFetchRequest',
-    declaration: 'export interface WebFetchRequest {\n    readonly url: string;\n    readonly timeoutMs?: number;\n}',
+    declaration: 'export interface WebFetchRequest {\n    readonly url: string;\n}',
   },
   {
     name: 'WebFetchResult',
-    declaration: 'export interface WebFetchResult {\n    readonly providerId: string;\n    readonly url: string;\n    readonly statusCode: number;\n    readonly body: WebFetchBody;\n    readonly truncated: boolean;\n}',
-  },
-  {
-    name: 'WebProviderStatus',
-    declaration: 'export type WebProviderStatus = {\n    readonly available: true;\n} | {\n    readonly available: false;\n    readonly reason: \'missing-credential\' | \'misconfigured\';\n};',
+    declaration: 'export interface WebFetchResult {\n    readonly url: string;\n    readonly statusCode: number;\n    readonly body: WebFetchBody;\n    readonly truncated: boolean;\n}',
   },
   {
     name: 'WebSearchProvider',
-    declaration: 'export interface WebSearchProvider {\n    readonly id: string;\n    status(): WebProviderStatus;\n    search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult>;\n}',
+    declaration: 'export interface WebSearchProvider {\n    readonly id: string;\n    available(): boolean;\n    search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;\n}',
   },
   {
     name: 'WebSearchRequest',
@@ -1080,7 +1072,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'WebSearchResult',
-    declaration: 'export interface WebSearchResult {\n    readonly providerId: string;\n    readonly query: string;\n    readonly content?: string;\n    readonly sources: readonly WebSearchSource[];\n    readonly truncated: boolean;\n}',
+    declaration: 'export interface WebSearchResult {\n    readonly content?: string;\n    readonly sources: readonly WebSearchSource[];\n    readonly truncated: boolean;\n}',
   },
   {
     name: 'WebSearchSource',

+ 1 - 1
packages/web/tool-web/README.md

@@ -32,7 +32,7 @@ Each tool is registered independently; a product that wants only one disables th
 
 Tool registration follows product **enablement**, not backend availability. A tool stays visible even when its selected provider is missing, misconfigured, ambiguous, or temporarily unavailable; the seam resolves the provider at execution time and execution fails with a structured `WebError` (e.g. `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_AMBIGUOUS`), which `ToolRegistry.execute()` turns into an error tool result the model can read and hooks/UI can route on. This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. To remove a web tool entirely, disable it here in config.
 
-The tool never calls a provider's `status()` and never enumerates providers — its only execution path is `ctx.web.search()` / `ctx.web.fetch()`, and provider unavailability reaches it as the structured `WebError` codes selection throws at execution time. Provider selection stays entirely inside the seam, with one owner.
+The tool never calls a provider's `available()` and never enumerates providers — its only execution path is `ctx.web.search()` / `ctx.web.fetch()`, and provider unavailability reaches it as the structured `WebError` codes selection throws at execution time. Provider selection stays entirely inside the seam, with one owner.
 
 ## Model Experience
 

+ 1 - 1
packages/web/tool-web/src/fetch.ts

@@ -96,7 +96,7 @@ export function applyWebFetchTool(ctx: Context, timeoutMs: number): void {
       const input = parseFetchArgs(args)
       const result = await ctx.web.fetch(
         { url: input.url },
-        exec.signal ? { signal: exec.signal } : undefined,
+        exec.signal,
       )
       return [{ type: 'text', text: formatFetchOutput(result) }]
     },

+ 1 - 1
packages/web/tool-web/src/search.ts

@@ -113,7 +113,7 @@ export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs:
       const input = parseSearchArgs(args)
       const result = await ctx.web.search(
         { query: input.query, maxResults },
-        exec.signal ? { signal: exec.signal } : undefined,
+        exec.signal,
       )
       return [{ type: 'text', text: formatSearchOutput(result) }]
     },

+ 13 - 6
packages/web/tool-web/tests/integration.spec.ts

@@ -134,7 +134,7 @@ describe('tool-call timeout returns TOOL_TIMEOUT (deadline wins over a slow fetc
     await tctx.plugin(ToolRegistry)
     await tctx.plugin(WebService, { fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID })
     // Provider backstop well ABOVE the tool-call budget, so the policy wins.
-    await tctx.plugin(WebFetchLocal, { timeoutMs: 30_000, maxTimeoutMs: 60_000 })
+    await tctx.plugin(WebFetchLocal, { timeoutMs: 30_000 })
     await tctx.plugin(TimeoutPolicy)
     // The tool-call budget is declared by tool-web config, enforced by the policy.
     tfiber = await tctx.plugin(ToolWeb, { fetchTimeoutMs: 50 })
@@ -156,11 +156,18 @@ describe('tool-call timeout returns TOOL_TIMEOUT (deadline wins over a slow fetc
     expect(text).toContain('timed out after 50ms')
   })
 
-  it('the provider backstop still protects a DIRECT ctx.web.fetch() call (no tool-call policy in that path)', async () => {
-    // A direct seam caller does not go through tools/execute, so the tool-call policy never
-    // applies; the provider's own timeout is the only budget. A short request hint must therefore
-    // produce provider-owned `WEB_FETCH_TIMEOUT`, never `TOOL_TIMEOUT`.
-    const err = await tctx.web.fetch({ url: slowBase, timeoutMs: 50 }).then(
+  it('the provider backstop still protects a direct provider call (no tool-call policy in that path)', async () => {
+    // A direct provider caller bypasses tools/execute, so a short configured backstop
+    // must produce provider-owned WEB_FETCH_TIMEOUT rather than TOOL_TIMEOUT.
+    const direct = new WebFetchLocal.LocalFetchProvider({
+      maxUrlLength: 2048,
+      maxResponseBytes: 5_000_000,
+      maxBodyChars: 100_000,
+      timeoutMs: 50,
+      maxRedirects: 5,
+      userAgent: 'integration-test',
+    })
+    const err = await direct.fetch({ url: slowBase }).then(
       () => undefined,
       (e: unknown) => e as { code?: string },
     )

+ 34 - 34
packages/web/tool-web/tests/tool-web.spec.ts

@@ -4,7 +4,7 @@ import { CallId } from '@deepseek-ai/dsh-llm'
 import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
 import ToolRegistry from '@deepseek-ai/dsh-tools'
 import WebService from '@deepseek-ai/dsh-web'
-import type { WebSearchProvider, WebSearchResult, WebProviderStatus } from '@deepseek-ai/dsh-web'
+import type { WebSearchProvider, WebSearchResult } from '@deepseek-ai/dsh-web'
 import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
 import {
   formatSearchOutput,
@@ -18,10 +18,10 @@ import {
   WEB_SEARCH_MAX_RESULTS,
 } from '@deepseek-ai/dsh-tool-web'
 
-const available: WebProviderStatus = { available: true }
+const available = true
 
-function searchProvider(result: WebSearchResult, status: WebProviderStatus = available): WebSearchProvider {
-  return { id: 'stub-search', status: () => status, search: () => Promise.resolve(result) }
+function searchProvider(result: WebSearchResult, isAvailable = available): WebSearchProvider {
+  return { id: 'stub-search', available: () => isAvailable, search: () => Promise.resolve(result) }
 }
 
 /** Mount the real registry, seam, and tool-web; return an executor helper. */
@@ -46,7 +46,7 @@ async function mountTools(opts: {
 describe('search formatting', () => {
   it('renders content, sources with titles/hostnames, snippets, and a citation reminder', () => {
     const out = formatSearchOutput({
-      providerId: 'p', query: 'q', content: 'an answer', truncated: false,
+      content: 'an answer', truncated: false,
       sources: [
         { url: 'https://a.test/x', title: 'A', snippet: 'about a', publishedAt: '2026-01-01' },
         { url: 'https://b.test/y' },
@@ -59,19 +59,19 @@ describe('search formatting', () => {
   })
 
   it('reports no results when there is neither content nor sources', () => {
-    expect(formatSearchOutput({ providerId: 'p', query: 'q', sources: [], truncated: false }))
+    expect(formatSearchOutput({ sources: [], truncated: false }))
       .toContain('No results found.')
   })
 
   it('renders content alone when there are no sources', () => {
-    const out = formatSearchOutput({ providerId: 'p', query: 'q', content: 'just an answer', sources: [], truncated: false })
+    const out = formatSearchOutput({ content: 'just an answer', sources: [], truncated: false })
     expect(out).toContain('just an answer')
     expect(out).not.toContain('No results found.')
     expect(out).not.toContain('Sources:')
   })
 
   it('notes truncation', () => {
-    const out = formatSearchOutput({ providerId: 'p', query: 'q', sources: [{ url: 'https://a.test' }], truncated: true })
+    const out = formatSearchOutput({ sources: [{ url: 'https://a.test' }], truncated: true })
     expect(out).toContain('Showing the first 1 sources')
   })
 
@@ -88,7 +88,7 @@ describe('search formatting', () => {
 describe('fetch formatting', () => {
   it('renders an html body to markdown text with a status header', () => {
     const out = formatFetchOutput({
-      providerId: 'p', url: 'https://a.test', statusCode: 200, truncated: false,
+      url: 'https://a.test', statusCode: 200, truncated: false,
       body: { kind: 'html', content: '<h1>Title</h1><p>Body text</p>' },
     })
     expect(out).toContain('Fetched https://a.test (HTTP 200)')
@@ -98,7 +98,7 @@ describe('fetch formatting', () => {
 
   it('passes a text body through and notes truncation', () => {
     const out = formatFetchOutput({
-      providerId: 'p', url: 'https://a.test', statusCode: 200, truncated: true,
+      url: 'https://a.test', statusCode: 200, truncated: true,
       body: { kind: 'text', content: 'plain' },
     })
     expect(out).toContain('plain')
@@ -155,7 +155,7 @@ describe('htmlToMarkdown', () => {
   })
 
   it('falls back to the raw URL as a source label when the URL is unparseable', () => {
-    const out = formatSearchOutput({ providerId: 'p', query: 'q', truncated: false, sources: [{ url: 'not a url' }] })
+    const out = formatSearchOutput({ truncated: false, sources: [{ url: 'not a url' }] })
     expect(out).toContain('[not a url](not a url)')
   })
 })
@@ -209,7 +209,7 @@ describe('tool-web registration', () => {
 describe('tool-web execution through the real registry', () => {
   it('executes web_search and formats the result', async () => {
     const result: WebSearchResult = {
-      providerId: 'stub-search', query: 'q', content: 'answer', truncated: false,
+      content: 'answer', truncated: false,
       sources: [{ url: 'https://a.test', title: 'A', snippet: 'snip' }],
     }
     const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider(result) })
@@ -228,8 +228,8 @@ describe('tool-web execution through the real registry', () => {
   })
 
   it('surfaces WEB_PROVIDER_AMBIGUOUS for multiple unconfigured providers', async () => {
-    const { ctx, fiber, call } = await mountTools({ search: searchProvider({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) })
-    ctx.web.registerSearchProvider({ id: 'other', status: () => available, search: () => Promise.resolve({ providerId: 'other', query: 'q', sources: [], truncated: false }) })
+    const { ctx, fiber, call } = await mountTools({ search: searchProvider({ sources: [], truncated: false }) })
+    ctx.web.registerSearchProvider({ id: 'other', available: () => available, search: () => Promise.resolve({ sources: [], truncated: false }) })
     const out = await call('web_search', { query: 'q' })
     expect(out.isError).toBe(true)
     expect(out.error?.code).toBe('WEB_PROVIDER_AMBIGUOUS')
@@ -237,7 +237,7 @@ describe('tool-web execution through the real registry', () => {
   })
 
   it('rejects invalid arguments with a structured INVALID_ARGS error', async () => {
-    const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) })
+    const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: searchProvider({ sources: [], truncated: false }) })
     const out = await call('web_search', { query: 123 })
     expect(out.isError).toBe(true)
     expect(out.error?.code).toBe('INVALID_ARGS')
@@ -249,14 +249,14 @@ describe('tool-web execution through the real registry', () => {
   })
 
   it('executes web_fetch, forwarding the url (no timeout param) and the abort signal to the seam', async () => {
-    const seen: { request?: { url: string; timeoutMs?: number }; signal?: AbortSignal | undefined } = {}
+    const seen: { request?: { url: string }; signal?: AbortSignal | undefined } = {}
     const fetchProvider = {
       id: 'stub-fetch',
-      status: () => available,
-      fetch: (request: { url: string; timeoutMs?: number }, exec?: { signal?: AbortSignal }) => {
+      available: () => available,
+      fetch: (request: { url: string }, signal?: AbortSignal) => {
         seen.request = request
-        seen.signal = exec?.signal
-        return Promise.resolve({ providerId: 'stub-fetch', url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false })
+        seen.signal = signal
+        return Promise.resolve({ url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false })
       },
     }
     const { ctx, fiber } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider })
@@ -271,21 +271,21 @@ describe('tool-web execution through the real registry', () => {
   })
 
   it('executes web_fetch with no caller signal (forwards undefined to the seam)', async () => {
-    const seen: { signal?: AbortSignal | undefined; passedExec?: boolean } = {}
+    const seen: { signal?: AbortSignal | undefined; passedSignal?: boolean } = {}
     const fetchProvider = {
       id: 'stub-fetch',
-      status: () => available,
-      fetch: (request: { url: string }, exec?: { signal?: AbortSignal }) => {
-        seen.passedExec = exec !== undefined
-        seen.signal = exec?.signal
-        return Promise.resolve({ providerId: 'stub-fetch', url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false })
+      available: () => available,
+      fetch: (request: { url: string }, signal?: AbortSignal) => {
+        seen.passedSignal = signal !== undefined
+        seen.signal = signal
+        return Promise.resolve({ url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false })
       },
     }
     const { ctx, fiber } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider })
-    // No signal on the execution: the tool passes `undefined` (not `{ signal: undefined }`).
+    // No signal on the execution: the tool passes `undefined`.
     const out = await ctx.tools.execute({ callId: CallId('fetch-2'), name: 'web_fetch', arguments: { url: 'https://a.test' } })
     expect(out.isError).toBe(false)
-    expect(seen.passedExec).toBe(false)
+    expect(seen.passedSignal).toBe(false)
     expect(seen.signal).toBeUndefined()
     await fiber.dispose()
   })
@@ -294,8 +294,8 @@ describe('tool-web execution through the real registry', () => {
     const seen: { signal?: AbortSignal | undefined } = {}
     const provider: WebSearchProvider = {
       id: 'stub-search',
-      status: () => available,
-      search: (_request, exec) => { seen.signal = exec?.signal; return Promise.resolve({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) },
+      available: () => available,
+      search: (_request, signal) => { seen.signal = signal; return Promise.resolve({ sources: [], truncated: false }) },
     }
     const { ctx, fiber } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider })
     const controller = new AbortController()
@@ -310,8 +310,8 @@ describe('searchMaxResults is plugin config', () => {
     const seen: { maxResults?: number | undefined } = {}
     const provider: WebSearchProvider = {
       id: 'stub-search',
-      status: () => available,
-      search: (request) => { seen.maxResults = request.maxResults; return Promise.resolve({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) },
+      available: () => available,
+      search: (request) => { seen.maxResults = request.maxResults; return Promise.resolve({ sources: [], truncated: false }) },
     }
     const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider })
     await call('web_search', { query: 'q' })
@@ -323,8 +323,8 @@ describe('searchMaxResults is plugin config', () => {
     const sources = Array.from({ length: 5 }, (_, i) => ({ url: `https://s${i}.test` }))
     const provider: WebSearchProvider = {
       id: 'stub-search',
-      status: () => available,
-      search: request => Promise.resolve({ providerId: 'stub-search', query: request.query, sources, truncated: false }),
+      available: () => available,
+      search: () => Promise.resolve({ sources, truncated: false }),
     }
     const { fiber, call } = await mountTools({ config: { searchMaxResults: 2 }, webConfig: { searchProvider: 'stub-search' }, search: provider })
     const out = await call('web_search', { query: 'q' })

+ 2 - 3
packages/web/web-fetch-local/README.md

@@ -8,7 +8,7 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i
 
 The provider owns **safe resource retrieval**: URL validation, HTTP transport, redirect policy, a resource-backstop timeout, abort propagation, byte caps, charset decoding, content-type classification, and binary rejection. `@deepseek-ai/dsh-tool-web` owns **presentation** (HTML→markdown, truncation formatting). A non-2xx HTTP response is a *result* (status code + decoded body), not an error; `WebError` is reserved for failures to safely retrieve or represent the resource.
 
-The provider's `timeoutMs`/`maxTimeoutMs` is a resource backstop for direct `ctx.web.fetch()` callers and misconfigured deployments, not the model-facing tool-call budget. [`dsh-timeout-policy`](../../timeout/timeout-policy/README.md) owns the `web_fetch` tool-call budget by arming `exec.signal`.
+The provider's `timeoutMs` is a resource backstop for direct `ctx.web.fetch()` callers and misconfigured deployments, not the model-facing tool-call budget. [`dsh-timeout-policy`](../../timeout/timeout-policy/README.md) owns the `web_fetch` tool-call budget by arming `exec.signal`.
 
 A shipping web-tool deployment sets the provider backstop above the tool budget, so model calls normally return `TOOL_TIMEOUT`. If the outer deadline reaches the provider first, the provider reports `WEB_ABORTED` and the outer policy replaces it with `TOOL_TIMEOUT`. `WEB_FETCH_TIMEOUT` therefore identifies a direct seam caller whose provider budget elapsed.
 
@@ -28,8 +28,7 @@ A shipping web-tool deployment sets the provider backstop above the tool budget,
 | `maxUrlLength` | `2048` | Maximum accepted request URL length. |
 | `maxResponseBytes` | `5_000_000` | Maximum response body size in bytes. |
 | `maxBodyChars` | `100_000` | Maximum decoded body length in characters. |
-| `timeoutMs` | `30_000` | Default fetch timeout — a resource backstop for direct `ctx.web.fetch()` callers, not the model-facing tool-call budget (that is `dsh-timeout-policy`). |
-| `maxTimeoutMs` | `120_000` | Upper bound for a per-request timeout override (direct callers). |
+| `timeoutMs` | `30_000` | Fetch timeout within Node's timer range — a resource backstop for direct `ctx.web.fetch()` callers, not the model-facing tool-call budget (that is `dsh-timeout-policy`). |
 | `maxRedirects` | `5` | Maximum same-origin redirect hops (`0` follows none). |
 | `userAgent` | `deepseek-harness/…` | `User-Agent` header. |
 

+ 12 - 7
packages/web/web-fetch-local/src/index.ts

@@ -13,6 +13,8 @@ import type {} from '@deepseek-ai/dsh-web'
 import { LocalFetchProvider } from './provider.ts'
 import type { LocalFetchLimits } from './provider.ts'
 
+const MAX_NODE_TIMER_DELAY_MS = 2_147_483_647
+
 export {
   LOCAL_FETCH_PROVIDER_ID,
   LocalFetchProvider,
@@ -38,10 +40,8 @@ export interface Config {
   maxResponseBytes?: number
   /** Maximum decoded body length in characters. */
   maxBodyChars?: number
-  /** Default fetch timeout in milliseconds. */
+  /** Default fetch timeout in milliseconds, within Node's timer range. */
   timeoutMs?: number
-  /** Upper bound for a per-request timeout override. */
-  maxTimeoutMs?: number
   /** Maximum number of same-origin redirect hops to follow. */
   maxRedirects?: number
   /** `User-Agent` header sent on every request. */
@@ -53,7 +53,6 @@ export const Config: z<Config> = z.object({
   maxResponseBytes: z.number().default(5_000_000),
   maxBodyChars: z.number().default(100_000),
   timeoutMs: z.number().default(30_000),
-  maxTimeoutMs: z.number().default(120_000),
   maxRedirects: z.number().default(5),
   userAgent: z.string().default(DEFAULT_USER_AGENT),
 })
@@ -68,6 +67,14 @@ function assertPositiveFinite(name: string, value: number): void {
   }
 }
 
+/** Node coerces larger timer delays to 1 ms, so reject them at configuration time. */
+function assertTimeoutMs(value: number): void {
+  assertPositiveFinite('timeoutMs', value)
+  if (value > MAX_NODE_TIMER_DELAY_MS) {
+    throw new Error(`web-fetch-local: timeoutMs must be no greater than ${MAX_NODE_TIMER_DELAY_MS}`)
+  }
+}
+
 /** The redirect hop cap must be a non-negative integer (0 follows no redirects). */
 function assertNonNegativeInteger(name: string, value: number): void {
   if (!Number.isInteger(value) || value < 0) {
@@ -82,15 +89,13 @@ export function apply(ctx: Context, config: Config): void {
   assertPositiveFinite('maxUrlLength', resolved.maxUrlLength)
   assertPositiveFinite('maxResponseBytes', resolved.maxResponseBytes)
   assertPositiveFinite('maxBodyChars', resolved.maxBodyChars)
-  assertPositiveFinite('timeoutMs', resolved.timeoutMs)
-  assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs)
+  assertTimeoutMs(resolved.timeoutMs)
   assertNonNegativeInteger('maxRedirects', resolved.maxRedirects)
   const limits: LocalFetchLimits = {
     maxUrlLength: resolved.maxUrlLength,
     maxResponseBytes: resolved.maxResponseBytes,
     maxBodyChars: resolved.maxBodyChars,
     timeoutMs: resolved.timeoutMs,
-    maxTimeoutMs: resolved.maxTimeoutMs,
     maxRedirects: resolved.maxRedirects,
     userAgent: resolved.userAgent,
   }

+ 7 - 11
packages/web/web-fetch-local/src/provider.ts

@@ -9,8 +9,8 @@
  */
 
 import { WebError } from '@deepseek-ai/dsh-web'
-import type { WebFetchBody, WebFetchProvider, WebFetchRequest, WebFetchResult, WebProviderStatus } from '@deepseek-ai/dsh-web'
-import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
+import type { WebFetchBody, WebFetchProvider, WebFetchRequest, WebFetchResult } from '@deepseek-ai/dsh-web'
+import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
 import { classifyContentType, decoderForCharset, isSameOrigin, parseCharset, validateFetchUrl } from './policy.ts'
 
 /** Resolved provider limits (the plugin's schemastery Config supplies defaults). */
@@ -23,8 +23,6 @@ export interface LocalFetchLimits {
   maxBodyChars: number
   /** Default fetch timeout in milliseconds. */
   timeoutMs: number
-  /** Upper bound for a per-request timeout override. */
-  maxTimeoutMs: number
   /** Maximum number of (same-origin) redirect hops to follow. */
   maxRedirects: number
   /** `User-Agent` header sent on every request. */
@@ -41,17 +39,16 @@ export class LocalFetchProvider implements WebFetchProvider {
   constructor(private readonly limits: LocalFetchLimits) {}
 
   /** No credentials to check — an anonymous public fetcher is always usable. */
-  status(): WebProviderStatus {
-    return { available: true }
+  available(): boolean {
+    return true
   }
 
-  async fetch(request: WebFetchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebFetchResult> {
-    if (exec?.signal?.aborted) throw new WebError('web fetch aborted', 'WEB_ABORTED')
-    const timeoutMs = clampTimeout(request.timeoutMs, this.limits.timeoutMs, this.limits.maxTimeoutMs)
+  async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult> {
+    if (signal?.aborted) throw new WebError('web fetch aborted', 'WEB_ABORTED')
 
     // One signal stops both the request and body read. The deadline's TimeoutReason later
     // distinguishes this provider's timeout from caller or outer-deadline cancellation.
-    using d = deadline(exec?.signal, timeoutMs, 'WEB_FETCH_TIMEOUT')
+    using d = deadline(signal, this.limits.timeoutMs, 'WEB_FETCH_TIMEOUT')
     return await this.followAndRead(request.url, d.signal)
   }
 
@@ -142,7 +139,6 @@ export class LocalFetchProvider implements WebFetchProvider {
     const body: WebFetchBody = kind === 'html' ? { kind: 'html', content } : { kind: 'text', content }
 
     return {
-      providerId: this.id,
       url: finalUrl.toString(),
       statusCode: response.status,
       body,

+ 12 - 11
packages/web/web-fetch-local/tests/fetch-local.spec.ts

@@ -12,7 +12,6 @@ const limits: LocalFetchLimits = {
   maxResponseBytes: 5_000_000,
   maxBodyChars: 100_000,
   timeoutMs: 5_000,
-  maxTimeoutMs: 10_000,
   maxRedirects: 5,
   userAgent: 'test-agent/1.0',
 }
@@ -82,7 +81,7 @@ describe('LocalFetchProvider success', () => {
   it('fetches a text body', async () => {
     handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('hello world') }
     const result = await provider().fetch({ url: base })
-    expect(result.providerId).toBe(LOCAL_FETCH_PROVIDER_ID)
+    expect(provider().available()).toBe(true)
     expect(result.statusCode).toBe(200)
     expect(result.body).toEqual({ kind: 'text', content: 'hello world' })
     expect(result.truncated).toBe(false)
@@ -287,14 +286,14 @@ describe('LocalFetchProvider invalid URLs and abort', () => {
   it('honors a pre-aborted signal', async () => {
     const controller = new AbortController()
     controller.abort()
-    await expect(provider().fetch({ url: base }, { signal: controller.signal }))
+    await expect(provider().fetch({ url: base }, controller.signal))
       .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
   })
 
   it('aborts an in-flight fetch via the signal', async () => {
     handler = (_req, _res) => { /* never responds */ }
     const controller = new AbortController()
-    const promise = provider().fetch({ url: base }, { signal: controller.signal })
+    const promise = provider().fetch({ url: base }, controller.signal)
     controller.abort()
     await expect(promise).rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
   })
@@ -325,11 +324,6 @@ describe('LocalFetchProvider invalid URLs and abort', () => {
       .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
   })
 
-  it('caps the per-request timeout at maxTimeoutMs', async () => {
-    handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('ok') }
-    const result = await provider({ maxTimeoutMs: 10_000 }).fetch({ url: base, timeoutMs: 999_999 })
-    expect(result.statusCode).toBe(200)
-  })
 })
 
 describe('LocalFetchProvider body cancellation on error paths', () => {
@@ -378,7 +372,7 @@ describe('web-fetch-local plugin registration', () => {
     await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
     const fiber = await ctx.plugin(fetchPlugin, {})
     await expect(ctx.web.fetch({ url: `${base}/` }))
-      .resolves.toMatchObject({ providerId: LOCAL_FETCH_PROVIDER_ID, statusCode: 200 })
+      .resolves.toMatchObject({ statusCode: 200 })
     await fiber.dispose()
     await expect(ctx.web.fetch({ url: `${base}/` }))
       .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))
@@ -402,6 +396,13 @@ describe('web-fetch-local plugin registration', () => {
       .rejects.toThrow(/timeoutMs must be a positive finite number/)
   })
 
+  it('rejects a timeout beyond Node timer range at construction', async () => {
+    const ctx = new Context()
+    await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
+    await expect(ctx.plugin(fetchPlugin, { timeoutMs: 2_147_483_648 }))
+      .rejects.toThrow(/timeoutMs must be no greater than 2147483647/)
+  })
+
   it('rejects a fractional redirect cap at construction', async () => {
     const ctx = new Context()
     await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
@@ -421,7 +422,7 @@ describe('web-fetch-local plugin registration', () => {
     await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
     const fiber = await ctx.plugin(fetchPlugin, { maxRedirects: 0 })
     await expect(ctx.web.fetch({ url: `${base}/` }))
-      .resolves.toMatchObject({ providerId: LOCAL_FETCH_PROVIDER_ID, statusCode: 200 })
+      .resolves.toMatchObject({ statusCode: 200 })
     await fiber.dispose()
   })
 })

+ 2 - 2
packages/web/web-search-deepseek/README.md

@@ -16,8 +16,8 @@ It reuses `$DEEPSEEK_API_KEY` (no new secret) but **not** `$DEEPSEEK_BASE_URL`:
 
 | Key | Default | Meaning |
 |---|---|---|
-| `apiKey` | `$DEEPSEEK_API_KEY` | DeepSeek API key. Empty/absent → provider `status()` reports `missing-credential`. Sent as both `x-api-key` and `Authorization: Bearer` (official vs Anthropic-compatible proxy). |
-| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic-compatible endpoint base; `/messages` is appended. Use a separate env var such as `$DEEPSEEK_SEARCH_BASE_URL` when overriding it; do not reuse `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions LLM adapter. An unparseable value makes `status()` report `misconfigured`. |
+| `apiKey` | `$DEEPSEEK_API_KEY` | DeepSeek API key. Empty/absent makes the provider unavailable. Sent as both `x-api-key` and `Authorization: Bearer` (official vs Anthropic-compatible proxy). |
+| `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic-compatible endpoint base; `/messages` is appended. Use a separate env var such as `$DEEPSEEK_SEARCH_BASE_URL` when overriding it; do not reuse `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions LLM adapter. An unparseable value makes the provider unavailable. |
 | `model` | `deepseek-v4-flash` | Anthropic-format model name. |
 | `apiVersion` | `2023-06-01` | `anthropic-version` header value. |
 | `maxTokens` | `4096` | Positive-integer upper bound on generated tokens for the Messages request. |

+ 11 - 13
packages/web/web-search-deepseek/src/provider.ts

@@ -8,7 +8,6 @@
 
 import { WebError } from '@deepseek-ai/dsh-web'
 import type {
-  WebProviderStatus,
   WebSearchProvider,
   WebSearchRequest,
   WebSearchResult,
@@ -50,7 +49,7 @@ const USER_AGENT = 'deepseek-harness/0.0.1'
 
 /** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
 export interface DeepSeekSearchProviderOptions {
-  /** DeepSeek API key. Empty/absent → `status()` reports `missing-credential`. */
+  /** DeepSeek API key. Empty/absent makes the provider unavailable. */
   apiKey: string
   /** Endpoint base; `/messages` is appended. */
   baseURL: string
@@ -93,12 +92,11 @@ export function citationSnippets(blocks: readonly ContentBlock[]): Map<string, s
  * the same URL across searches). The seam owns the final `maxResults` truncation, so
  * `truncated` is always `false` here.
  *
- * @param query - the original request query, echoed on the result.
  * @param response - the parsed Messages response body.
  * @returns the normalized result with deduped, snippet-joined sources.
  * @throws {@link WebError} when native search produced no result block.
  */
-export function mapAnthropicResponse(query: string, response: AnthropicResponse): WebSearchResult {
+export function mapAnthropicResponse(response: AnthropicResponse): WebSearchResult {
   const blocks = response.content ?? []
   const resultBlocks = blocks.filter(
     (block): block is WebSearchToolResultBlock => block.type === 'web_search_tool_result',
@@ -126,7 +124,7 @@ export function mapAnthropicResponse(query: string, response: AnthropicResponse)
       })
     }
   }
-  return { providerId: DEEPSEEK_PROVIDER_ID, query, sources, truncated: false }
+  return { sources, truncated: false }
 }
 
 /** The DeepSeek-backed search provider. */
@@ -135,14 +133,14 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
 
   constructor(private readonly options: DeepSeekSearchProviderOptions) {}
 
-  status(): WebProviderStatus {
-    if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' }
-    if (!URL.canParse(this.options.baseURL)) return { available: false, reason: 'misconfigured' }
-    if (!isPositiveInteger(this.options.maxTokens) || !isPositiveInteger(this.options.maxUses)) return { available: false, reason: 'misconfigured' }
-    return { available: true }
+  available(): boolean {
+    return this.options.apiKey.length > 0
+      && URL.canParse(this.options.baseURL)
+      && isPositiveInteger(this.options.maxTokens)
+      && isPositiveInteger(this.options.maxUses)
   }
 
-  async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebSearchResult> {
+  async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
     let response: Response
     try {
       response = await fetch(`${this.options.baseURL}/messages`, {
@@ -166,7 +164,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
           }],
           tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: this.options.maxUses }],
         }),
-        ...exec?.signal ? { signal: exec.signal } : {},
+        ...signal !== undefined ? { signal } : {},
       })
     } catch (error: unknown) {
       if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error })
@@ -194,7 +192,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider {
 
     try {
       const payload = await response.json() as AnthropicResponse
-      return mapAnthropicResponse(request.query, payload)
+      return mapAnthropicResponse(payload)
     } catch (error: unknown) {
       if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error })
       if (error instanceof WebError) throw error

+ 0 - 1
packages/web/web-search-deepseek/tests/deepseek.e2e.ts

@@ -29,7 +29,6 @@ maybe('DeepSeekSearchProvider real API', () => {
       maxUses: DEEPSEEK_DEFAULT_MAX_USES,
     })
     const result = await provider.search({ query: 'What is the DeepSeek Harness SDK?', maxResults: 5 })
-    expect(result.providerId).toBe('deepseek')
     expect(result.sources.length).toBeGreaterThan(0)
     for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//)
   }, 60_000)

+ 18 - 25
packages/web/web-search-deepseek/tests/deepseek.spec.ts

@@ -64,10 +64,8 @@ describe('citationSnippets', () => {
 
 describe('mapAnthropicResponse', () => {
   it('joins result items to citation snippets and maps page_age to publishedAt', () => {
-    const result = mapAnthropicResponse('q', searchResponse())
+    const result = mapAnthropicResponse(searchResponse())
     expect(result).toEqual({
-      providerId: DEEPSEEK_PROVIDER_ID,
-      query: 'q',
       sources: [
         { url: 'https://a.test', title: 'A', snippet: 'excerpt for A', publishedAt: '2026-02-02' },
         { url: 'https://b.test', title: 'B' },
@@ -77,7 +75,7 @@ describe('mapAnthropicResponse', () => {
   })
 
   it('dedupes repeated urls across result blocks (first wins)', () => {
-    const result = mapAnthropicResponse('q', {
+    const result = mapAnthropicResponse({
       content: [
         { type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: 'first' }] },
         { type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: 'second' }] },
@@ -87,7 +85,7 @@ describe('mapAnthropicResponse', () => {
   })
 
   it('skips non-result items and items with an empty url', () => {
-    const result = mapAnthropicResponse('q', {
+    const result = mapAnthropicResponse({
       content: [{
         type: 'web_search_tool_result',
         content: [
@@ -101,14 +99,14 @@ describe('mapAnthropicResponse', () => {
   })
 
   it('omits optional fields when absent or empty', () => {
-    const result = mapAnthropicResponse('q', {
+    const result = mapAnthropicResponse({
       content: [{ type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: '', page_age: '' }] }],
     })
     expect(result.sources).toEqual([{ url: 'https://a.test' }])
   })
 
   it('tolerates a text block with no citations', () => {
-    const result = mapAnthropicResponse('q', {
+    const result = mapAnthropicResponse({
       content: [
         { type: 'text', text: 'no citations here' },
         { type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test', title: 'A' }] },
@@ -118,7 +116,7 @@ describe('mapAnthropicResponse', () => {
   })
 
   it('tolerates a result block with no content array', () => {
-    const result = mapAnthropicResponse('q', {
+    const result = mapAnthropicResponse({
       content: [
         { type: 'web_search_tool_result' },
         { type: 'web_search_tool_result', content: [{ type: 'web_search_result', url: 'https://a.test' }] },
@@ -128,38 +126,33 @@ describe('mapAnthropicResponse', () => {
   })
 
   it('throws WEB_PROVIDER_ERROR (strict mode) when no result block is present', () => {
-    expect(() => mapAnthropicResponse('q', { content: [{ type: 'text', text: 'just prose, no search' }] }))
+    expect(() => mapAnthropicResponse({ content: [{ type: 'text', text: 'just prose, no search' }] }))
       .toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
   })
 
   it('throws WEB_PROVIDER_ERROR when content is absent entirely', () => {
-    expect(() => mapAnthropicResponse('q', {}))
+    expect(() => mapAnthropicResponse({}))
       .toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
   })
 })
 
-describe('DeepSeekSearchProvider status', () => {
+describe('DeepSeekSearchProvider availability', () => {
   it('is unavailable without a key', () => {
-    expect(new DeepSeekSearchProvider({ ...options, apiKey: '' }).status())
-      .toEqual({ available: false, reason: 'missing-credential' })
+    expect(new DeepSeekSearchProvider({ ...options, apiKey: '' }).available()).toBe(false)
   })
 
   it('is available with a key', () => {
-    expect(new DeepSeekSearchProvider(options).status()).toEqual({ available: true })
+    expect(new DeepSeekSearchProvider(options).available()).toBe(true)
   })
 
   it('is misconfigured when the base URL is unparseable', () => {
-    expect(new DeepSeekSearchProvider({ ...options, baseURL: 'not a url' }).status())
-      .toEqual({ available: false, reason: 'misconfigured' })
+    expect(new DeepSeekSearchProvider({ ...options, baseURL: 'not a url' }).available()).toBe(false)
   })
 
   it('is misconfigured when request limits are not positive integers', () => {
-    expect(new DeepSeekSearchProvider({ ...options, maxTokens: 0 }).status())
-      .toEqual({ available: false, reason: 'misconfigured' })
-    expect(new DeepSeekSearchProvider({ ...options, maxUses: 0 }).status())
-      .toEqual({ available: false, reason: 'misconfigured' })
-    expect(new DeepSeekSearchProvider({ ...options, maxUses: 1.5 }).status())
-      .toEqual({ available: false, reason: 'misconfigured' })
+    expect(new DeepSeekSearchProvider({ ...options, maxTokens: 0 }).available()).toBe(false)
+    expect(new DeepSeekSearchProvider({ ...options, maxUses: 0 }).available()).toBe(false)
+    expect(new DeepSeekSearchProvider({ ...options, maxUses: 1.5 }).available()).toBe(false)
   })
 })
 
@@ -186,7 +179,7 @@ describe('DeepSeekSearchProvider request mapping', () => {
     const fetchMock = vi.fn(async () => jsonResponse(searchResponse()))
     vi.stubGlobal('fetch', fetchMock)
     const controller = new AbortController()
-    await new DeepSeekSearchProvider(options).search({ query: 'q' }, { signal: controller.signal })
+    await new DeepSeekSearchProvider(options).search({ query: 'q' }, controller.signal)
     const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
     expect(init.signal).toBe(controller.signal)
   })
@@ -268,7 +261,7 @@ describe('web-search-deepseek plugin registration', () => {
     const ctx = new Context()
     await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
     const fiber = await ctx.plugin(deepseekPlugin, { apiKey: 'ds-key' })
-    await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: DEEPSEEK_PROVIDER_ID })
+    await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ truncated: false })
     await fiber.dispose()
     await expect(ctx.web.search({ query: 'q' }))
       .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))
@@ -318,7 +311,7 @@ describe('web-search-deepseek plugin registration', () => {
     const unwrapped = loader.unwrapExports(deepseekPlugin) as Parameters<Context['plugin']>[0]
     // A collapsed export shape (dropped inject) would throw "without inject" here.
     const fiber = await ctx.plugin(unwrapped, { apiKey: 'ds-key' })
-    await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: DEEPSEEK_PROVIDER_ID })
+    await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ truncated: false })
     await fiber.dispose()
   })
 

+ 2 - 2
packages/web/web-search-exa/README.md

@@ -8,8 +8,8 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i
 
 | Key | Default | Meaning |
 |---|---|---|
-| `apiKey` | `$EXA_API_KEY` | Exa API key. Empty/absent → provider `status()` reports `missing-credential` (the seam reports `configured-unavailable`/`none`). |
-| `baseURL` | `https://api.exa.ai` | Endpoint base; `/search` is appended. An unparseable value makes `status()` report `misconfigured`. |
+| `apiKey` | `$EXA_API_KEY` | Exa API key. Empty/absent makes the provider unavailable. |
+| `baseURL` | `https://api.exa.ai` | Endpoint base; `/search` is appended. An unparseable value makes the provider unavailable. |
 | `searchType` | `auto` | Retrieval mode sent as Exa's `type`: `auto` (Exa decides), `keyword`, or `neural`. |
 | `numResults` | (unset) | Default result count when a request carries no `maxResults`. Unset sends no default. Must be a positive integer. |
 | `highlightsPerResult` | `1` | Highlight sentences requested per result (Exa's `highlightsPerUrl`). Must be a positive integer. |

+ 11 - 14
packages/web/web-search-exa/src/provider.ts

@@ -8,7 +8,6 @@
 
 import { WebError } from '@deepseek-ai/dsh-web'
 import type {
-  WebProviderStatus,
   WebSearchProvider,
   WebSearchRequest,
   WebSearchResult,
@@ -33,7 +32,7 @@ const USER_AGENT = 'deepseek-harness/0.0.1'
 
 /** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
 export interface ExaSearchProviderOptions {
-  /** Exa API key. Empty/absent → `status()` reports `missing-credential`. */
+  /** Exa API key. Empty/absent makes the provider unavailable. */
   apiKey: string
   /** Endpoint base; `/search` is appended. */
   baseURL: string
@@ -68,18 +67,17 @@ export function mapExaResult(result: ExaResult): WebSearchSource | undefined {
 /**
  * Map an Exa response envelope to a normalized search result.
  *
- * @param query - the original request query, echoed on the result.
  * @param response - the parsed `POST /search` response body.
  * @returns the normalized result; snippet-less entries are dropped
  *   ({@link mapExaResult}).
  */
-export function mapExaResponse(query: string, response: ExaSearchResponse): WebSearchResult {
+export function mapExaResponse(response: ExaSearchResponse): WebSearchResult {
   const sources = (response.results ?? [])
     .map(mapExaResult)
     .filter((source): source is WebSearchSource => source !== undefined)
   // Exa returns no generated answer, so `content` is omitted. The seam owns the
   // final `maxResults` truncation, so this provider reports `truncated: false`.
-  return { providerId: EXA_PROVIDER_ID, query, sources, truncated: false }
+  return { sources, truncated: false }
 }
 
 /** The Exa-backed search provider. */
@@ -88,15 +86,14 @@ export class ExaSearchProvider implements WebSearchProvider {
 
   constructor(private readonly options: ExaSearchProviderOptions) {}
 
-  status(): WebProviderStatus {
-    if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' }
-    if (!isValidBaseUrl(this.options.baseURL)) return { available: false, reason: 'misconfigured' }
-    if (!isPositiveInteger(this.options.highlightsPerResult)) return { available: false, reason: 'misconfigured' }
-    if (this.options.numResults !== undefined && !isPositiveInteger(this.options.numResults)) return { available: false, reason: 'misconfigured' }
-    return { available: true }
+  available(): boolean {
+    return this.options.apiKey.length > 0
+      && isValidBaseUrl(this.options.baseURL)
+      && isPositiveInteger(this.options.highlightsPerResult)
+      && (this.options.numResults === undefined || isPositiveInteger(this.options.numResults))
   }
 
-  async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebSearchResult> {
+  async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
     // A per-request bound wins over the configured default; either may be absent.
     const numResults = request.maxResults ?? this.options.numResults
     let response: Response
@@ -115,7 +112,7 @@ export class ExaSearchProvider implements WebSearchProvider {
           contents: { highlights: { highlightsPerUrl: this.options.highlightsPerResult } },
           ...numResults !== undefined ? { numResults } : {},
         }),
-        ...exec?.signal ? { signal: exec.signal } : {},
+        ...signal !== undefined ? { signal } : {},
       })
     } catch (error: unknown) {
       if (isAbortError(error)) throw new WebError('Exa search aborted', 'WEB_ABORTED', { cause: error })
@@ -143,7 +140,7 @@ export class ExaSearchProvider implements WebSearchProvider {
 
     try {
       const payload = await response.json() as ExaSearchResponse
-      return mapExaResponse(request.query, payload)
+      return mapExaResponse(payload)
     } catch (error: unknown) {
       if (isAbortError(error)) throw new WebError('Exa search aborted', 'WEB_ABORTED', { cause: error })
       throw new WebError(`Exa returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })

+ 0 - 1
packages/web/web-search-exa/tests/exa.e2e.ts

@@ -17,7 +17,6 @@ maybe('ExaSearchProvider real API', () => {
       highlightsPerResult: EXA_DEFAULT_HIGHLIGHTS_PER_RESULT,
     })
     const result = await provider.search({ query: 'DeepSeek Harness SDK', maxResults: 5 })
-    expect(result.providerId).toBe('exa')
     expect(result.sources.length).toBeGreaterThan(0)
     for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//)
   }, 30_000)

+ 11 - 18
packages/web/web-search-exa/tests/exa.spec.ts

@@ -38,7 +38,7 @@ describe('Exa result mapping', () => {
   })
 
   it('maps a response to a result with no content and filtered sources', () => {
-    const result = mapExaResponse('q', {
+    const result = mapExaResponse({
       results: [
         { url: 'https://a.test', highlights: ['one'] },
         { url: 'https://b.test' },
@@ -46,8 +46,6 @@ describe('Exa result mapping', () => {
       ],
     })
     expect(result).toEqual({
-      providerId: EXA_PROVIDER_ID,
-      query: 'q',
       sources: [
         { url: 'https://a.test', snippet: 'one' },
         { url: 'https://c.test', title: 'C', snippet: 'three' },
@@ -58,36 +56,31 @@ describe('Exa result mapping', () => {
   })
 
   it('tolerates a missing results array', () => {
-    expect(mapExaResponse('q', {}).sources).toEqual([])
+    expect(mapExaResponse({}).sources).toEqual([])
   })
 
 })
 
-describe('ExaSearchProvider status', () => {
+describe('ExaSearchProvider availability', () => {
   it('is unavailable without a key', () => {
-    expect(new ExaSearchProvider({ ...options, apiKey: '' }).status())
-      .toEqual({ available: false, reason: 'missing-credential' })
+    expect(new ExaSearchProvider({ ...options, apiKey: '' }).available()).toBe(false)
   })
 
   it('is available with a key', () => {
-    expect(new ExaSearchProvider(options).status()).toEqual({ available: true })
+    expect(new ExaSearchProvider(options).available()).toBe(true)
   })
 
   it('is misconfigured when the base URL is unparseable', () => {
-    expect(new ExaSearchProvider({ ...options, baseURL: 'not a url' }).status())
-      .toEqual({ available: false, reason: 'misconfigured' })
+    expect(new ExaSearchProvider({ ...options, baseURL: 'not a url' }).available()).toBe(false)
   })
 
   it('is misconfigured when highlightsPerResult is not a positive integer', () => {
-    expect(new ExaSearchProvider({ ...options, highlightsPerResult: 0 }).status())
-      .toEqual({ available: false, reason: 'misconfigured' })
-    expect(new ExaSearchProvider({ ...options, highlightsPerResult: 1.5 }).status())
-      .toEqual({ available: false, reason: 'misconfigured' })
+    expect(new ExaSearchProvider({ ...options, highlightsPerResult: 0 }).available()).toBe(false)
+    expect(new ExaSearchProvider({ ...options, highlightsPerResult: 1.5 }).available()).toBe(false)
   })
 
   it('is misconfigured when numResults is set but not a positive integer', () => {
-    expect(new ExaSearchProvider({ ...options, numResults: -1 }).status())
-      .toEqual({ available: false, reason: 'misconfigured' })
+    expect(new ExaSearchProvider({ ...options, numResults: -1 }).available()).toBe(false)
   })
 })
 
@@ -139,7 +132,7 @@ describe('ExaSearchProvider request mapping', () => {
     const fetchMock = vi.fn(async () => jsonResponse({ results: [] }))
     vi.stubGlobal('fetch', fetchMock)
     const controller = new AbortController()
-    await new ExaSearchProvider(options).search({ query: 'q' }, { signal: controller.signal })
+    await new ExaSearchProvider(options).search({ query: 'q' }, controller.signal)
     const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
     expect(init.signal).toBe(controller.signal)
   })
@@ -209,7 +202,7 @@ describe('web-search-exa plugin registration', () => {
     const ctx = new Context()
     await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID })
     const fiber = await ctx.plugin(exaPlugin, { apiKey: 'exa-key' })
-    await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: EXA_PROVIDER_ID })
+    await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ sources: [], truncated: false })
     await fiber.dispose()
     await expect(ctx.web.search({ query: 'q' }))
       .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))

+ 2 - 2
packages/web/web-search-perplexity/README.md

@@ -8,8 +8,8 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i
 
 | Key | Default | Meaning |
 |---|---|---|
-| `apiKey` | `$PERPLEXITY_API_KEY` | Perplexity API key. Empty/absent → provider `status()` reports `missing-credential`. |
-| `baseURL` | `https://api.perplexity.ai` | Endpoint base; `/chat/completions` is appended. An unparseable value makes `status()` report `misconfigured`. |
+| `apiKey` | `$PERPLEXITY_API_KEY` | Perplexity API key. Empty/absent makes the provider unavailable. |
+| `baseURL` | `https://api.perplexity.ai` | Endpoint base; `/chat/completions` is appended. An unparseable value makes the provider unavailable. |
 | `model` | `sonar` | Search model name. |
 | `maxTokens` | `1024` | Upper bound on generated answer tokens (`max_tokens`). Must be a positive integer. |
 | `searchRecency` | (unset) | Recency window sent as `search_recency_filter`: `day`, `week`, `month`, or `year`. Unset sends no filter. |

+ 9 - 14
packages/web/web-search-perplexity/src/provider.ts

@@ -8,7 +8,6 @@
 
 import { WebError } from '@deepseek-ai/dsh-web'
 import type {
-  WebProviderStatus,
   WebSearchProvider,
   WebSearchRequest,
   WebSearchResult,
@@ -36,7 +35,7 @@ const USER_AGENT = 'deepseek-harness/0.0.1'
 
 /** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
 export interface PerplexitySearchProviderOptions {
-  /** Perplexity API key. Empty/absent → `status()` reports `missing-credential`. */
+  /** Perplexity API key. Empty/absent makes the provider unavailable. */
   apiKey: string
   /** Endpoint base; `/chat/completions` is appended. */
   baseURL: string
@@ -68,18 +67,15 @@ export function mapPerplexityResult(result: PerplexitySearchResult): WebSearchSo
  * structured `search_results[]`; falls back to URL-only `citations[]` (those
  * sources carry just a `url`) only when `search_results` is absent.
  *
- * @param query - the original request query, echoed on the result.
  * @param response - the parsed chat-completions response body.
  * @returns the normalized result; `content` is omitted when the answer is empty.
  */
-export function mapPerplexityResponse(query: string, response: PerplexityResponse): WebSearchResult {
+export function mapPerplexityResponse(response: PerplexityResponse): WebSearchResult {
   const content = response.choices?.[0]?.message?.content
   const sources: WebSearchSource[] = response.search_results !== undefined
     ? response.search_results.map(mapPerplexityResult)
     : (response.citations ?? []).map(url => ({ url }))
   return {
-    providerId: PERPLEXITY_PROVIDER_ID,
-    query,
     ...content != null && content.length > 0 ? { content } : {},
     sources,
     truncated: false,
@@ -95,15 +91,14 @@ export class PerplexitySearchProvider implements WebSearchProvider {
   // Availability checks stay beside each provider's distinct config contract;
   // a shared base class would obscure which fields make this backend usable.
   /* jscpd:ignore-start */
-  status(): WebProviderStatus {
-    if (this.options.apiKey.length === 0) return { available: false, reason: 'missing-credential' }
-    if (!URL.canParse(this.options.baseURL)) return { available: false, reason: 'misconfigured' }
-    if (!isPositiveInteger(this.options.maxTokens)) return { available: false, reason: 'misconfigured' }
-    return { available: true }
+  available(): boolean {
+    return this.options.apiKey.length > 0
+      && URL.canParse(this.options.baseURL)
+      && isPositiveInteger(this.options.maxTokens)
   }
   /* jscpd:ignore-end */
 
-  async search(request: WebSearchRequest, exec?: { readonly signal?: AbortSignal }): Promise<WebSearchResult> {
+  async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
     let response: Response
     try {
       response = await fetch(`${this.options.baseURL}/chat/completions`, {
@@ -120,7 +115,7 @@ export class PerplexitySearchProvider implements WebSearchProvider {
           messages: [{ role: 'user', content: request.query }],
           ...this.options.searchRecency !== undefined ? { search_recency_filter: this.options.searchRecency } : {},
         }),
-        ...exec?.signal ? { signal: exec.signal } : {},
+        ...signal !== undefined ? { signal } : {},
       })
     } catch (error: unknown) {
       if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error })
@@ -148,7 +143,7 @@ export class PerplexitySearchProvider implements WebSearchProvider {
 
     try {
       const payload = await response.json() as PerplexityResponse
-      return mapPerplexityResponse(request.query, payload)
+      return mapPerplexityResponse(payload)
     } catch (error: unknown) {
       if (isAbortError(error)) throw new WebError('Perplexity search aborted', 'WEB_ABORTED', { cause: error })
       throw new WebError(`Perplexity returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error })

+ 0 - 1
packages/web/web-search-perplexity/tests/perplexity.e2e.ts

@@ -17,7 +17,6 @@ maybe('PerplexitySearchProvider real API', () => {
       maxTokens: PERPLEXITY_DEFAULT_MAX_TOKENS,
     })
     const result = await provider.search({ query: 'What is the DeepSeek Harness SDK?', maxResults: 5 })
-    expect(result.providerId).toBe('perplexity')
     expect(result.content ?? '').not.toBe('')
     for (const source of result.sources) expect(source.url).toMatch(/^https?:\/\//)
   }, 30_000)

+ 15 - 21
packages/web/web-search-perplexity/tests/perplexity.spec.ts

@@ -20,7 +20,7 @@ afterEach(() => {
 
 describe('Perplexity response mapping', () => {
   it('maps the answer and prefers structured search_results', () => {
-    const result = mapPerplexityResponse('q', {
+    const result = mapPerplexityResponse({
       choices: [{ message: { content: 'the answer' } }],
       search_results: [
         { url: 'https://a.test', title: 'A', snippet: 'snip', date: '2026-02-02' },
@@ -29,8 +29,6 @@ describe('Perplexity response mapping', () => {
       citations: ['https://ignored.test'],
     })
     expect(result).toEqual({
-      providerId: PERPLEXITY_PROVIDER_ID,
-      query: 'q',
       content: 'the answer',
       sources: [
         { url: 'https://a.test', title: 'A', snippet: 'snip', publishedAt: '2026-02-02' },
@@ -41,7 +39,7 @@ describe('Perplexity response mapping', () => {
   })
 
   it('falls back to URL-only citations when search_results is absent', () => {
-    const result = mapPerplexityResponse('q', {
+    const result = mapPerplexityResponse({
       choices: [{ message: { content: 'answer' } }],
       citations: ['https://a.test', 'https://b.test'],
     })
@@ -49,43 +47,39 @@ describe('Perplexity response mapping', () => {
   })
 
   it('omits content when the answer is empty or missing', () => {
-    expect(mapPerplexityResponse('q', { citations: [] }).content).toBeUndefined()
-    expect(mapPerplexityResponse('q', { choices: [{ message: { content: '' } }] }).content).toBeUndefined()
-    expect(mapPerplexityResponse('q', { choices: [{ message: { content: null } }] }).content).toBeUndefined()
+    expect(mapPerplexityResponse({ citations: [] }).content).toBeUndefined()
+    expect(mapPerplexityResponse({ choices: [{ message: { content: '' } }] }).content).toBeUndefined()
+    expect(mapPerplexityResponse({ choices: [{ message: { content: null } }] }).content).toBeUndefined()
   })
 
   it('omits null/empty optional source fields', () => {
-    const result = mapPerplexityResponse('q', {
+    const result = mapPerplexityResponse({
       search_results: [{ url: 'https://a.test', title: null, snippet: '', date: null }],
     })
     expect(result.sources).toEqual([{ url: 'https://a.test' }])
   })
 
   it('yields no sources when neither search_results nor citations are present', () => {
-    expect(mapPerplexityResponse('q', { choices: [{ message: { content: 'a' } }] }).sources).toEqual([])
+    expect(mapPerplexityResponse({ choices: [{ message: { content: 'a' } }] }).sources).toEqual([])
   })
 })
 
-describe('PerplexitySearchProvider status', () => {
+describe('PerplexitySearchProvider availability', () => {
   it('is unavailable without a key', () => {
-    expect(new PerplexitySearchProvider({ ...options, apiKey: '' }).status())
-      .toEqual({ available: false, reason: 'missing-credential' })
+    expect(new PerplexitySearchProvider({ ...options, apiKey: '' }).available()).toBe(false)
   })
 
   it('is available with a key', () => {
-    expect(new PerplexitySearchProvider(options).status()).toEqual({ available: true })
+    expect(new PerplexitySearchProvider(options).available()).toBe(true)
   })
 
   it('is misconfigured when the base URL is unparseable', () => {
-    expect(new PerplexitySearchProvider({ ...options, baseURL: 'not a url' }).status())
-      .toEqual({ available: false, reason: 'misconfigured' })
+    expect(new PerplexitySearchProvider({ ...options, baseURL: 'not a url' }).available()).toBe(false)
   })
 
   it('is misconfigured when maxTokens is not a positive integer', () => {
-    expect(new PerplexitySearchProvider({ ...options, maxTokens: 0 }).status())
-      .toEqual({ available: false, reason: 'misconfigured' })
-    expect(new PerplexitySearchProvider({ ...options, maxTokens: 1.5 }).status())
-      .toEqual({ available: false, reason: 'misconfigured' })
+    expect(new PerplexitySearchProvider({ ...options, maxTokens: 0 }).available()).toBe(false)
+    expect(new PerplexitySearchProvider({ ...options, maxTokens: 1.5 }).available()).toBe(false)
   })
 })
 
@@ -114,7 +108,7 @@ describe('PerplexitySearchProvider request mapping', () => {
     const fetchMock = vi.fn(async () => jsonResponse({ citations: [] }))
     vi.stubGlobal('fetch', fetchMock)
     const controller = new AbortController()
-    await new PerplexitySearchProvider(options).search({ query: 'q' }, { signal: controller.signal })
+    await new PerplexitySearchProvider(options).search({ query: 'q' }, controller.signal)
     const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
     expect(init.signal).toBe(controller.signal)
   })
@@ -190,7 +184,7 @@ describe('web-search-perplexity plugin registration', () => {
     const ctx = new Context()
     await ctx.plugin(WebService, { searchProvider: PERPLEXITY_PROVIDER_ID })
     const fiber = await ctx.plugin(perplexityPlugin, { apiKey: 'pplx-key' })
-    await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: PERPLEXITY_PROVIDER_ID })
+    await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ content: 'a', sources: [] })
     await fiber.dispose()
     await expect(ctx.web.search({ query: 'q' }))
       .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))

+ 5 - 5
packages/web/web/README.md

@@ -19,8 +19,8 @@ Search and fetch share no request schema and no business logic, but they are del
 | Member | Semantics |
 |---|---|
 | `registerSearchProvider(provider)` / `registerFetchProvider(provider)` | Register a backend. Throws `WebError` `WEB_DUPLICATE_PROVIDER` on a duplicate id within that capability kind. Returns a disposer. Disposed with the calling fiber. |
-| `search(request, exec?)` | Resolve the search provider and run one search. Enforces `request.maxResults` on the result (truncates `sources[]`, sets `truncated`). Throws `WebError` when the capability cannot run. |
-| `fetch(request, exec?)` | Resolve the fetch provider and retrieve one URL. A non-2xx response is a result, not a throw. Throws `WebError` for failures to safely retrieve or represent the resource. |
+| `search(request, signal?)` | Resolve the search provider and run one search. Enforces `request.maxResults` on the result (truncates `sources[]`, sets `truncated`). Throws `WebError` when the capability cannot run. |
+| `fetch(request, signal?)` | Resolve the fetch provider and retrieve one URL. A non-2xx response is a result, not a throw. Throws `WebError` for failures to safely retrieve or represent the resource. |
 
 Providers register **capabilities**, not tools. `dsh-tool-web` is the only owner of model-facing names, descriptions, prompt guidance, JSON schemas, and presentation.
 
@@ -30,18 +30,18 @@ Selection never depends on registration, config, or HMR order. A capability has
 
 | Situation | Execution |
 |---|---|
-| configured id registered and `status().available` | runs that provider |
+| configured id registered and `available()` | runs that provider |
 | configured id not registered | `WEB_PROVIDER_CONFIGURED_MISSING` |
 | configured id registered but unavailable | `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` |
 | no id, exactly one registered usable provider | runs it |
 | no id, no usable provider | `WEB_PROVIDER_UNAVAILABLE` |
 | no id, multiple usable providers | `WEB_PROVIDER_AMBIGUOUS` |
 
-The failure branches throw `WebError`, whose structured code (plus message detail — the missing id, the ambiguous candidate set) is the surface callers route on. A provider's own `status()` is a cheap local check (credential presence, parseable config) that feeds this execution-time selection and **must not make network calls**; `dsh-tool-web` never calls a provider's `status()`it executes through `ctx.web.search()`/`fetch()` and routes on the thrown codes, so provider selection has one owner.
+The failure branches throw `WebError`, whose structured code (plus message detail — the missing id, the ambiguous candidate set) is the surface callers route on. A provider's own `available()` is a cheap local check (credential presence, parseable config) that feeds this execution-time selection and **must not make network calls**; `dsh-tool-web` never calls it — the tool executes through `ctx.web.search()`/`fetch()` and routes on the thrown codes, so provider selection has one owner.
 
 ## Vocabulary
 
-`WebSearchRequest` (`query`, `maxResults?`) → `WebSearchResult` (`providerId`, `query`, `content?`, `sources[]`, `truncated`); each `WebSearchSource` has a required `url` and optional `title`/`snippet`/`publishedAt` (Perplexity citations may be URL-only). `WebFetchRequest` (`url`, `timeoutMs?`) → `WebFetchResult` (`providerId`, final `url`, `statusCode`, `body`, `truncated`); `WebFetchBody` is a CLOSED discriminated union (`html` | `text`) owned here — consumers `switch` to exhaustiveness so a new kind breaks their compilation until handled. See `src/types.ts` for the full contracts and the `WebError` code taxonomy.
+`WebSearchRequest` (`query`, `maxResults?`) → `WebSearchResult` (`content?`, `sources[]`, `truncated`); each `WebSearchSource` has a required `url` and optional `title`/`snippet`/`publishedAt` (Perplexity citations may be URL-only). `WebFetchRequest` (`url`) → `WebFetchResult` (final `url`, `statusCode`, `body`, `truncated`); cancellation is a direct optional `AbortSignal` argument to `search()`/`fetch()`. `WebFetchBody` is a CLOSED discriminated union (`html` | `text`) owned here — consumers `switch` to exhaustiveness so a new kind breaks their compilation until handled. See `src/types.ts` for the full contracts and the `WebError` code taxonomy.
 
 ## Model Experience
 

+ 10 - 14
packages/web/web/src/index.ts

@@ -9,11 +9,9 @@
 import { Context, Service } from 'cordis'
 import z from 'schemastery'
 import type {
-  WebExecContext,
   WebFetchProvider,
   WebFetchRequest,
   WebFetchResult,
-  WebProviderStatus,
   WebSearchProvider,
   WebSearchRequest,
   WebSearchResult,
@@ -24,12 +22,10 @@ export {
   WebError,
 } from './types.ts'
 export type {
-  WebExecContext,
   WebFetchBody,
   WebFetchProvider,
   WebFetchRequest,
   WebFetchResult,
-  WebProviderStatus,
   WebSearchProvider,
   WebSearchRequest,
   WebSearchResult,
@@ -67,7 +63,7 @@ export interface WebServiceConfig {
  * The web access service. Registered as `ctx.web` (one instance per context).
  *
  * Selection semantics (resolved at execution time, never order-dependent):
- * - A configured id that is registered and `status().available` → that provider.
+ * - A configured id that is registered and `available()` → that provider.
  * - A configured id not registered → `WEB_PROVIDER_CONFIGURED_MISSING`.
  * - A configured id registered but unavailable →
  *   `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`.
@@ -138,15 +134,15 @@ export class WebService extends Service {
    * capability cannot run. The seam enforces `request.maxResults` on the result:
    * if the provider over-returns, `sources[]` is truncated and `truncated` set.
    * @param request - the query plus result-shaping options.
-   * @param exec - the tool-execution context, forwarded to the provider.
+   * @param signal - optional cancellation signal forwarded to the provider.
    * @returns the provider's results, capped to `request.maxResults`.
    */
-  async search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult> {
+  async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
     const provider = resolveProvider({
       providers: this.searchProviders,
       ...this.searchProviderId !== undefined ? { configuredId: this.searchProviderId } : {},
     })
-    const result = await provider.search(request, exec)
+    const result = await provider.search(request, signal)
     return capSources(result, request.maxResults)
   }
 
@@ -155,21 +151,21 @@ export class WebService extends Service {
    * call time with the selection rules above; throws {@link WebError} when the
    * capability cannot run. A non-2xx response is a result, not a throw.
    * @param request - the URL plus retrieval options.
-   * @param exec - the tool-execution context, forwarded to the provider.
+   * @param signal - optional cancellation signal forwarded to the provider.
    * @returns the retrieval outcome; non-2xx responses resolve descriptively.
    */
-  async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult> {
+  async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult> {
     const provider = resolveProvider({
       providers: this.fetchProviders,
       ...this.fetchProviderId !== undefined ? { configuredId: this.fetchProviderId } : {},
     })
-    return provider.fetch(request, exec)
+    return provider.fetch(request, signal)
   }
 }
 
 interface ResolvableProvider {
   readonly id: string
-  status(): WebProviderStatus
+  available(): boolean
 }
 
 /** Resolve the selected provider or throw the matching {@link WebError}. */
@@ -180,12 +176,12 @@ function resolveProvider<P extends ResolvableProvider>(selection: Selection<P>):
     if (!provider) {
       throw new WebError(`configured web provider "${configuredId}" is not registered`, 'WEB_PROVIDER_CONFIGURED_MISSING')
     }
-    if (!provider.status().available) {
+    if (!provider.available()) {
       throw new WebError(`configured web provider "${configuredId}" is registered but unavailable`, 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE')
     }
     return provider
   }
-  const usable = [...providers.values()].filter(provider => provider.status().available)
+  const usable = [...providers.values()].filter(provider => provider.available())
   const [single] = usable
   if (single === undefined) {
     throw new WebError('no usable web provider is registered', 'WEB_PROVIDER_UNAVAILABLE')

+ 10 - 42
packages/web/web/src/types.ts

@@ -7,19 +7,6 @@
 
 import { HarnessError } from '@deepseek-ai/dsh-llm'
 
-/**
- * Execution control threaded from the tool layer through the seam into a
- * provider's network requests, stream readers, and expensive decoding. It is
- * NOT business input: the first version carries only `signal` so `tool-web` can
- * propagate turn cancellation, tool timeout, and agent disposal. It deliberately
- * does NOT carry `ToolExecution`, which would make `dsh-web` depend on
- * `dsh-tools`.
- */
-export interface WebExecContext {
-  /** Abort signal a provider must honor for its network/decoding work. */
-  readonly signal?: AbortSignal
-}
-
 /**
  * What one search-capable backend can return. The model-facing argument is just
  * a query; `maxResults` is a `dsh-tool-web`-layer bound passed through unchanged
@@ -44,10 +31,6 @@ export interface WebSearchRequest {
  * when it cut `sources[]` down to `maxResults`.
  */
 export interface WebSearchResult {
-  /** Id of the provider that produced this result. */
-  readonly providerId: string
-  /** Echo of the query the provider answered. */
-  readonly query: string
   /** Optional provider-generated answer text, search context, or summary. */
   readonly content?: string
   /** Citeable sources, already truncated to the request's `maxResults`. */
@@ -71,14 +54,13 @@ export interface WebSearchSource {
 }
 
 /**
- * What one fetch-capable backend is asked to retrieve. `timeoutMs` is an
- * optional positive hint the provider caps. The request deliberately omits
- * `format`, `prompt`, and extraction controls — those are presentation or
- * higher-level LLM concerns, not safe-retrieval inputs.
+ * What one fetch-capable backend is asked to retrieve. The request deliberately
+ * omits timeout, format, prompt, and extraction controls: cancellation is a
+ * direct execution argument, while presentation and higher-level LLM concerns
+ * belong outside safe retrieval.
  */
 export interface WebFetchRequest {
   readonly url: string
-  readonly timeoutMs?: number
 }
 
 /**
@@ -88,8 +70,6 @@ export interface WebFetchRequest {
  * represent the resource.
  */
 export interface WebFetchResult {
-  /** Id of the provider that produced this result. */
-  readonly providerId: string
   /** The final URL after allowed redirects (the request URL is in the request). */
   readonly url: string
   /** HTTP status code of the fetched response. */
@@ -113,18 +93,6 @@ export type WebFetchBody =
   | { readonly kind: 'html'; readonly content: string }
   | { readonly kind: 'text'; readonly content: string }
 
-/**
- * Whether one concrete provider implementation is usable, by cheap local checks
- * only (credential presence, parseable endpoint config). A provider `status()`
- * must NOT make network calls. It is an input to execution-time selection, not
- * a health system: `WebService.search()`/`fetch()` read it to pick a usable
- * provider, and selection failure surfaces as the structured {@link WebError}
- * codes callers route on.
- */
-export type WebProviderStatus =
-  | { readonly available: true }
-  | { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' }
-
 /**
  * A search-capable backend. Registered with `ctx.web.registerSearchProvider`.
  * `id` is a stable string, unique within the search capability kind.
@@ -132,9 +100,9 @@ export type WebProviderStatus =
 export interface WebSearchProvider {
   readonly id: string
   /** Cheap local usability check; must not make network calls. */
-  status(): WebProviderStatus
-  /** Run one search; honor `exec.signal` for cancellation. */
-  search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult>
+  available(): boolean
+  /** Run one search; honor `signal` for cancellation. */
+  search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
 }
 
 /**
@@ -144,9 +112,9 @@ export interface WebSearchProvider {
 export interface WebFetchProvider {
   readonly id: string
   /** Cheap local usability check; must not make network calls. */
-  status(): WebProviderStatus
-  /** Retrieve one URL; honor `exec.signal` for cancellation. */
-  fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult>
+  available(): boolean
+  /** Retrieve one URL; honor `signal` for cancellation. */
+  fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
 }
 
 /**

+ 20 - 22
packages/web/web/tests/web.spec.ts

@@ -4,7 +4,6 @@ import WebService, {
   WebError,
   type WebFetchProvider,
   type WebFetchResult,
-  type WebProviderStatus,
   type WebSearchProvider,
   type WebSearchRequest,
   type WebSearchResult,
@@ -13,25 +12,25 @@ import WebService, {
 /** A scripted search provider for contract tests. */
 function makeSearchProvider(
   id: string,
-  status: WebProviderStatus,
+  available: boolean,
   search: (request: WebSearchRequest) => Promise<WebSearchResult>,
 ): WebSearchProvider {
-  return { id, status: () => status, search: request => search(request) }
+  return { id, available: () => available, search: request => search(request) }
 }
 
-function makeFetchProvider(id: string, status: WebProviderStatus, result: WebFetchResult): WebFetchProvider {
-  return { id, status: () => status, fetch: () => Promise.resolve(result) }
+function makeFetchProvider(id: string, available: boolean, result: WebFetchResult): WebFetchProvider {
+  return { id, available: () => available, fetch: () => Promise.resolve(result) }
 }
 
-const available: WebProviderStatus = { available: true }
-const unavailable: WebProviderStatus = { available: false, reason: 'missing-credential' }
+const available = true
+const unavailable = false
 
-function searchResult(providerId: string, overrides: Partial<WebSearchResult> = {}): WebSearchResult {
-  return { providerId, query: 'q', sources: [], truncated: false, ...overrides }
+function searchResult(marker: string, overrides: Partial<WebSearchResult> = {}): WebSearchResult {
+  return { content: marker, sources: [], truncated: false, ...overrides }
 }
 
-function fetchResult(providerId: string): WebFetchResult {
-  return { providerId, url: 'https://example.com', statusCode: 200, body: { kind: 'text', content: 'hi' }, truncated: false }
+function fetchResult(marker: string): WebFetchResult {
+  return { url: 'https://example.com', statusCode: 200, body: { kind: 'text', content: marker }, truncated: false }
 }
 
 /** Mount a WebService on a fresh root context with the given config. */
@@ -46,7 +45,7 @@ describe('WebService registration', () => {
     const { web } = await mountWeb()
 
     const dispose = web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
-    await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'exa' })
+    await expect(web.search({ query: 'q' })).resolves.toMatchObject({ content: 'exa' })
 
     dispose()
     await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' }))
@@ -70,7 +69,7 @@ describe('WebService registration', () => {
     const fiber = await ctx.plugin(Object.assign((inner: Context) => {
       inner.web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
     }, { inject: ['web'] }))
-    await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'exa' })
+    await expect(web.search({ query: 'q' })).resolves.toMatchObject({ content: 'exa' })
     await fiber.dispose()
     await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' }))
   })
@@ -111,26 +110,26 @@ describe('WebService execution resolution', () => {
     const { web } = await mountWeb({ searchProvider: 'perplexity' })
     web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
     web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity'))))
-    await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'perplexity' })
+    await expect(web.search({ query: 'q' })).resolves.toMatchObject({ content: 'perplexity' })
   })
 
   it('ignores unusable providers when auto-selecting', async () => {
     const { web } = await mountWeb()
     web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
     web.registerSearchProvider(makeSearchProvider('perplexity', unavailable, () => Promise.resolve(searchResult('perplexity'))))
-    await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'exa' })
+    await expect(web.search({ query: 'q' })).resolves.toMatchObject({ content: 'exa' })
   })
 
   it('does not let registration order change auto-selection', async () => {
     const a = await mountWeb()
     a.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa'))))
     a.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity'))))
-    await expect(a.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'perplexity' })
+    await expect(a.web.search({ query: 'q' })).resolves.toMatchObject({ content: 'perplexity' })
 
     const b = await mountWeb()
     b.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity'))))
     b.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa'))))
-    await expect(b.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'perplexity' })
+    await expect(b.web.search({ query: 'q' })).resolves.toMatchObject({ content: 'perplexity' })
   })
 
   it('runs the selected provider and returns its result', async () => {
@@ -139,7 +138,6 @@ describe('WebService execution resolution', () => {
       searchResult('exa', { content: 'answer', sources: [{ url: 'https://a' }] }),
     )))
     const result = await web.search({ query: 'q' })
-    expect(result.providerId).toBe('exa')
     expect(result.content).toBe('answer')
     expect(result.sources).toEqual([{ url: 'https://a' }])
   })
@@ -149,11 +147,11 @@ describe('WebService execution resolution', () => {
     const seen: (AbortSignal | undefined)[] = []
     web.registerSearchProvider({
       id: 'exa',
-      status: () => available,
-      search: (_request, exec) => { seen.push(exec?.signal); return Promise.resolve(searchResult('exa')) },
+      available: () => available,
+      search: (_request, signal) => { seen.push(signal); return Promise.resolve(searchResult('exa')) },
     })
     const controller = new AbortController()
-    await web.search({ query: 'q' }, { signal: controller.signal })
+    await web.search({ query: 'q' }, controller.signal)
     expect(seen[0]).toBe(controller.signal)
   })
 })
@@ -195,7 +193,7 @@ describe('WebService fetch capability', () => {
     const { web } = await mountWeb()
     web.registerFetchProvider(makeFetchProvider('local-http', available, fetchResult('local-http')))
     const result = await web.fetch({ url: 'https://example.com' })
-    expect(result.providerId).toBe('local-http')
+    expect(result.body.content).toBe('local-http')
     expect(result.statusCode).toBe(200)
   })
 

+ 0 - 1
scripts/type-equiv.manifest.json

@@ -145,7 +145,6 @@
     { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchRequest", "source": "packages/web/web/src/types.ts" },
     { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchResult", "source": "packages/web/web/src/types.ts" },
     { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchBody", "source": "packages/web/web/src/types.ts" },
-    { "doc": "docs/core-data-structures/web.md", "symbol": "WebProviderStatus", "source": "packages/web/web/src/types.ts" },
 
     { "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowStartRequest", "source": "packages/workflow/workflow/src/types.ts" },
     { "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowMeta", "source": "packages/workflow/workflow/src/types.ts" },