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

This commit is contained in:
Yichen Jiang
2026-07-15 13:59:52 +08:00
committed by GitHub
35 changed files with 250 additions and 354 deletions

View File

@@ -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` ## `@deepseek-ai/dsh-web-fetch-local`
@@ -1101,10 +1101,8 @@ export interface Config {
maxResponseBytes?: number maxResponseBytes?: number
/** Maximum decoded body length in characters. */ /** Maximum decoded body length in characters. */
maxBodyChars?: number maxBodyChars?: number
/** Default fetch timeout in milliseconds. */ /** Default fetch timeout in milliseconds, within Node's timer range. */
timeoutMs?: number timeoutMs?: number
/** Upper bound for a per-request timeout override. */
maxTimeoutMs?: number
/** Maximum number of same-origin redirect hops to follow. */ /** Maximum number of same-origin redirect hops to follow. */
maxRedirects?: number maxRedirects?: number
/** `User-Agent` header sent on every request. */ /** `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` ## `@deepseek-ai/dsh-web-search-deepseek`

View File

@@ -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): 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 not registered → `WEB_PROVIDER_CONFIGURED_MISSING`.
- A configured id registered but unavailable → `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. - A configured id registered but unavailable → `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`.
- No id configured, exactly one registered usable provider → that provider. - 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 ```ts cordis-catalog
registerSearchProvider(provider: WebSearchProvider): () => void registerSearchProvider(provider: WebSearchProvider): () => void
registerFetchProvider(provider: WebFetchProvider): () => void registerFetchProvider(provider: WebFetchProvider): () => void
async search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult> async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult> 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) ## `ctx.workflows` — `WorkflowService` (abstract seam)

View File

@@ -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 | | [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 | | [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 | | [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 | | [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. > 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.

View File

@@ -25,8 +25,6 @@ interface WebSearchRequest {
```ts type-equiv ```ts type-equiv
interface WebSearchResult { interface WebSearchResult {
readonly providerId: string
readonly query: string
readonly content?: string readonly content?: string
readonly sources: readonly WebSearchSource[] readonly sources: readonly WebSearchSource[]
readonly truncated: boolean readonly truncated: boolean
@@ -49,7 +47,6 @@ interface WebSearchSource {
```ts type-equiv ```ts type-equiv
interface WebFetchRequest { interface WebFetchRequest {
readonly url: string 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 ```ts type-equiv
interface WebFetchResult { interface WebFetchResult {
readonly providerId: string
readonly url: string readonly url: string
readonly statusCode: number readonly statusCode: number
readonly body: WebFetchBody readonly body: WebFetchBody
@@ -73,15 +69,9 @@ type WebFetchBody =
| { readonly kind: 'text'; readonly content: string } | { readonly kind: 'text'; readonly content: string }
``` ```
## Provider status ## Provider availability
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. 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.
```ts type-equiv
type WebProviderStatus =
| { readonly available: true }
| { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' }
```
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. 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.

View File

@@ -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 | | [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 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 | | [Simplify session-log representation](proposed/simplification/2026-07-12-simplify-session-log-representation.md) | 2026-07-12 |
### Architecture ### 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 | | [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 | | [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 | | [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 ### Architecture

View File

@@ -64,7 +64,7 @@ flowchart LR
toolWeb -->|ctx.tools.register| webFetch["tool: web_fetch"] 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. 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 ```ts
interface WebSearchProvider { interface WebSearchProvider {
readonly id: string readonly id: string
status(): WebProviderStatus available(): boolean
search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult> search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
} }
interface WebFetchProvider { interface WebFetchProvider {
readonly id: string readonly id: string
status(): WebProviderStatus available(): boolean
fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult> fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
} }
interface WebService { interface WebService {
registerSearchProvider(provider: WebSearchProvider): () => void registerSearchProvider(provider: WebSearchProvider): () => void
registerFetchProvider(provider: WebFetchProvider): () => void registerFetchProvider(provider: WebFetchProvider): () => void
search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult> search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult> fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
}
interface WebExecContext {
readonly signal?: AbortSignal
} }
``` ```
`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 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 availability 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 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.
`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. `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.
`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. 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.
```ts
type WebProviderStatus =
| { readonly available: true }
| { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' }
```
Selection must not depend on registration order. Cordis load order, config ordering, and HMR timing are not product semantics. Selection must not depend on registration order. Cordis load order, config ordering, and HMR timing are not product semantics.
| Situation | Execution behavior | | 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 not registered | fails with `WEB_PROVIDER_CONFIGURED_MISSING` |
| A configured provider id is registered but unavailable | fails with `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` | | 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 | | 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 { interface WebSearchResult {
readonly providerId: string
readonly query: string
readonly content?: string readonly content?: string
readonly sources: readonly WebSearchSource[] readonly sources: readonly WebSearchSource[]
readonly truncated: boolean 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: The seam request stays smaller than OpenCode's model-facing tool:
- `url`: required HTTP(S) URL. - `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. 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 ```ts
interface WebFetchRequest { interface WebFetchRequest {
readonly url: string readonly url: string
readonly timeoutMs?: number
} }
interface WebFetchResult { interface WebFetchResult {
readonly providerId: string
readonly url: string readonly url: string
readonly statusCode: number readonly statusCode: number
readonly body: WebFetchBody 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` 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. 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. 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.

View File

@@ -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`. `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. `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.

View File

@@ -1,6 +1,6 @@
# RFC: Prune unused web seam fields # RFC: Prune unused web seam fields
Status: proposed Status: implemented
## Problem ## 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. `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 ## 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. **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. 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.
- 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
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. 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.

View File

@@ -241,8 +241,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
methods: [ methods: [
'registerSearchProvider(provider: WebSearchProvider): () => void', 'registerSearchProvider(provider: WebSearchProvider): () => void',
'registerFetchProvider(provider: WebFetchProvider): () => void', 'registerFetchProvider(provider: WebFetchProvider): () => void',
'async search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult>', 'async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>',
'async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult>', 'async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>',
], ],
}, },
{ {
@@ -1046,33 +1046,25 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'UserInteractionProvider', name: 'UserInteractionProvider',
declaration: 'export interface UserInteractionProvider {\n ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>;\n}', declaration: 'export interface UserInteractionProvider {\n ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>;\n}',
}, },
{
name: 'WebExecContext',
declaration: 'export interface WebExecContext {\n readonly signal?: AbortSignal;\n}',
},
{ {
name: 'WebFetchBody', name: 'WebFetchBody',
declaration: 'export type WebFetchBody = {\n readonly kind: \'html\';\n readonly content: string;\n} | {\n readonly kind: \'text\';\n readonly content: string;\n};', declaration: 'export type WebFetchBody = {\n readonly kind: \'html\';\n readonly content: string;\n} | {\n readonly kind: \'text\';\n readonly content: string;\n};',
}, },
{ {
name: 'WebFetchProvider', 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', 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', 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}', declaration: 'export interface WebFetchResult {\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};',
}, },
{ {
name: 'WebSearchProvider', 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', name: 'WebSearchRequest',
@@ -1080,7 +1072,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
}, },
{ {
name: 'WebSearchResult', 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', name: 'WebSearchSource',

View File

@@ -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. 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 ## Model Experience

View File

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

View File

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

View File

@@ -134,7 +134,7 @@ describe('tool-call timeout returns TOOL_TIMEOUT (deadline wins over a slow fetc
await tctx.plugin(ToolRegistry) await tctx.plugin(ToolRegistry)
await tctx.plugin(WebService, { fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID }) await tctx.plugin(WebService, { fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID })
// Provider backstop well ABOVE the tool-call budget, so the policy wins. // 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) await tctx.plugin(TimeoutPolicy)
// The tool-call budget is declared by tool-web config, enforced by the policy. // The tool-call budget is declared by tool-web config, enforced by the policy.
tfiber = await tctx.plugin(ToolWeb, { fetchTimeoutMs: 50 }) 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') 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 () => { it('the provider backstop still protects a direct provider 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 // A direct provider caller bypasses tools/execute, so a short configured backstop
// applies; the provider's own timeout is the only budget. A short request hint must therefore // must produce provider-owned WEB_FETCH_TIMEOUT rather than TOOL_TIMEOUT.
// produce provider-owned `WEB_FETCH_TIMEOUT`, never `TOOL_TIMEOUT`. const direct = new WebFetchLocal.LocalFetchProvider({
const err = await tctx.web.fetch({ url: slowBase, timeoutMs: 50 }).then( 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, () => undefined,
(e: unknown) => e as { code?: string }, (e: unknown) => e as { code?: string },
) )

View File

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

View File

@@ -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 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. 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. | | `maxUrlLength` | `2048` | Maximum accepted request URL length. |
| `maxResponseBytes` | `5_000_000` | Maximum response body size in bytes. | | `maxResponseBytes` | `5_000_000` | Maximum response body size in bytes. |
| `maxBodyChars` | `100_000` | Maximum decoded body length in characters. | | `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`). | | `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`). |
| `maxTimeoutMs` | `120_000` | Upper bound for a per-request timeout override (direct callers). |
| `maxRedirects` | `5` | Maximum same-origin redirect hops (`0` follows none). | | `maxRedirects` | `5` | Maximum same-origin redirect hops (`0` follows none). |
| `userAgent` | `deepseek-harness/…` | `User-Agent` header. | | `userAgent` | `deepseek-harness/…` | `User-Agent` header. |

View File

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

View File

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

View File

@@ -12,7 +12,6 @@ const limits: LocalFetchLimits = {
maxResponseBytes: 5_000_000, maxResponseBytes: 5_000_000,
maxBodyChars: 100_000, maxBodyChars: 100_000,
timeoutMs: 5_000, timeoutMs: 5_000,
maxTimeoutMs: 10_000,
maxRedirects: 5, maxRedirects: 5,
userAgent: 'test-agent/1.0', userAgent: 'test-agent/1.0',
} }
@@ -82,7 +81,7 @@ describe('LocalFetchProvider success', () => {
it('fetches a text body', async () => { it('fetches a text body', async () => {
handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('hello world') } handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('hello world') }
const result = await provider().fetch({ url: base }) 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.statusCode).toBe(200)
expect(result.body).toEqual({ kind: 'text', content: 'hello world' }) expect(result.body).toEqual({ kind: 'text', content: 'hello world' })
expect(result.truncated).toBe(false) expect(result.truncated).toBe(false)
@@ -287,14 +286,14 @@ describe('LocalFetchProvider invalid URLs and abort', () => {
it('honors a pre-aborted signal', async () => { it('honors a pre-aborted signal', async () => {
const controller = new AbortController() const controller = new AbortController()
controller.abort() 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' })) .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
}) })
it('aborts an in-flight fetch via the signal', async () => { it('aborts an in-flight fetch via the signal', async () => {
handler = (_req, _res) => { /* never responds */ } handler = (_req, _res) => { /* never responds */ }
const controller = new AbortController() const controller = new AbortController()
const promise = provider().fetch({ url: base }, { signal: controller.signal }) const promise = provider().fetch({ url: base }, controller.signal)
controller.abort() controller.abort()
await expect(promise).rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) 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' })) .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', () => { 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 }) await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
const fiber = await ctx.plugin(fetchPlugin, {}) const fiber = await ctx.plugin(fetchPlugin, {})
await expect(ctx.web.fetch({ url: `${base}/` })) await expect(ctx.web.fetch({ url: `${base}/` }))
.resolves.toMatchObject({ providerId: LOCAL_FETCH_PROVIDER_ID, statusCode: 200 }) .resolves.toMatchObject({ statusCode: 200 })
await fiber.dispose() await fiber.dispose()
await expect(ctx.web.fetch({ url: `${base}/` })) await expect(ctx.web.fetch({ url: `${base}/` }))
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' })) .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/) .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 () => { it('rejects a fractional redirect cap at construction', async () => {
const ctx = new Context() const ctx = new Context()
await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) 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 }) await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
const fiber = await ctx.plugin(fetchPlugin, { maxRedirects: 0 }) const fiber = await ctx.plugin(fetchPlugin, { maxRedirects: 0 })
await expect(ctx.web.fetch({ url: `${base}/` })) await expect(ctx.web.fetch({ url: `${base}/` }))
.resolves.toMatchObject({ providerId: LOCAL_FETCH_PROVIDER_ID, statusCode: 200 }) .resolves.toMatchObject({ statusCode: 200 })
await fiber.dispose() await fiber.dispose()
}) })
}) })

View File

@@ -16,8 +16,8 @@ It reuses `$DEEPSEEK_API_KEY` (no new secret) but **not** `$DEEPSEEK_BASE_URL`:
| Key | Default | Meaning | | 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). | | `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 `status()` report `misconfigured`. | | `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. | | `model` | `deepseek-v4-flash` | Anthropic-format model name. |
| `apiVersion` | `2023-06-01` | `anthropic-version` header value. | | `apiVersion` | `2023-06-01` | `anthropic-version` header value. |
| `maxTokens` | `4096` | Positive-integer upper bound on generated tokens for the Messages request. | | `maxTokens` | `4096` | Positive-integer upper bound on generated tokens for the Messages request. |

View File

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

View File

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

View File

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

View File

@@ -8,8 +8,8 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i
| Key | Default | Meaning | | Key | Default | Meaning |
|---|---|---| |---|---|---|
| `apiKey` | `$EXA_API_KEY` | Exa API key. Empty/absent → provider `status()` reports `missing-credential` (the seam reports `configured-unavailable`/`none`). | | `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 `status()` report `misconfigured`. | | `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`. | | `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. | | `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. | | `highlightsPerResult` | `1` | Highlight sentences requested per result (Exa's `highlightsPerUrl`). Must be a positive integer. |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,8 +19,8 @@ Search and fetch share no request schema and no business logic, but they are del
| Member | Semantics | | 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. | | `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. | | `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, 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. | | `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. 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 | | 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 not registered | `WEB_PROVIDER_CONFIGURED_MISSING` |
| configured id registered but unavailable | `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` | | configured id registered but unavailable | `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` |
| no id, exactly one registered usable provider | runs it | | no id, exactly one registered usable provider | runs it |
| no id, no usable provider | `WEB_PROVIDER_UNAVAILABLE` | | no id, no usable provider | `WEB_PROVIDER_UNAVAILABLE` |
| no id, multiple usable providers | `WEB_PROVIDER_AMBIGUOUS` | | 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 ## 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 ## Model Experience

View File

@@ -9,11 +9,9 @@
import { Context, Service } from 'cordis' import { Context, Service } from 'cordis'
import z from 'schemastery' import z from 'schemastery'
import type { import type {
WebExecContext,
WebFetchProvider, WebFetchProvider,
WebFetchRequest, WebFetchRequest,
WebFetchResult, WebFetchResult,
WebProviderStatus,
WebSearchProvider, WebSearchProvider,
WebSearchRequest, WebSearchRequest,
WebSearchResult, WebSearchResult,
@@ -24,12 +22,10 @@ export {
WebError, WebError,
} from './types.ts' } from './types.ts'
export type { export type {
WebExecContext,
WebFetchBody, WebFetchBody,
WebFetchProvider, WebFetchProvider,
WebFetchRequest, WebFetchRequest,
WebFetchResult, WebFetchResult,
WebProviderStatus,
WebSearchProvider, WebSearchProvider,
WebSearchRequest, WebSearchRequest,
WebSearchResult, WebSearchResult,
@@ -67,7 +63,7 @@ export interface WebServiceConfig {
* The web access service. Registered as `ctx.web` (one instance per context). * The web access service. Registered as `ctx.web` (one instance per context).
* *
* Selection semantics (resolved at execution time, never order-dependent): * 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 not registered → `WEB_PROVIDER_CONFIGURED_MISSING`.
* - A configured id registered but unavailable → * - A configured id registered but unavailable →
* `WEB_PROVIDER_CONFIGURED_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: * capability cannot run. The seam enforces `request.maxResults` on the result:
* if the provider over-returns, `sources[]` is truncated and `truncated` set. * if the provider over-returns, `sources[]` is truncated and `truncated` set.
* @param request - the query plus result-shaping options. * @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`. * @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({ const provider = resolveProvider({
providers: this.searchProviders, providers: this.searchProviders,
...this.searchProviderId !== undefined ? { configuredId: this.searchProviderId } : {}, ...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) 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 * 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. * capability cannot run. A non-2xx response is a result, not a throw.
* @param request - the URL plus retrieval options. * @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. * @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({ const provider = resolveProvider({
providers: this.fetchProviders, providers: this.fetchProviders,
...this.fetchProviderId !== undefined ? { configuredId: this.fetchProviderId } : {}, ...this.fetchProviderId !== undefined ? { configuredId: this.fetchProviderId } : {},
}) })
return provider.fetch(request, exec) return provider.fetch(request, signal)
} }
} }
interface ResolvableProvider { interface ResolvableProvider {
readonly id: string readonly id: string
status(): WebProviderStatus available(): boolean
} }
/** Resolve the selected provider or throw the matching {@link WebError}. */ /** Resolve the selected provider or throw the matching {@link WebError}. */
@@ -180,12 +176,12 @@ function resolveProvider<P extends ResolvableProvider>(selection: Selection<P>):
if (!provider) { if (!provider) {
throw new WebError(`configured web provider "${configuredId}" is not registered`, 'WEB_PROVIDER_CONFIGURED_MISSING') 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') throw new WebError(`configured web provider "${configuredId}" is registered but unavailable`, 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE')
} }
return provider return provider
} }
const usable = [...providers.values()].filter(provider => provider.status().available) const usable = [...providers.values()].filter(provider => provider.available())
const [single] = usable const [single] = usable
if (single === undefined) { if (single === undefined) {
throw new WebError('no usable web provider is registered', 'WEB_PROVIDER_UNAVAILABLE') throw new WebError('no usable web provider is registered', 'WEB_PROVIDER_UNAVAILABLE')

View File

@@ -7,19 +7,6 @@
import { HarnessError } from '@deepseek-ai/dsh-llm' 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 * 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 * 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`. * when it cut `sources[]` down to `maxResults`.
*/ */
export interface WebSearchResult { 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. */ /** Optional provider-generated answer text, search context, or summary. */
readonly content?: string readonly content?: string
/** Citeable sources, already truncated to the request's `maxResults`. */ /** 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 * What one fetch-capable backend is asked to retrieve. The request deliberately
* optional positive hint the provider caps. The request deliberately omits * omits timeout, format, prompt, and extraction controls: cancellation is a
* `format`, `prompt`, and extraction controls — those are presentation or * direct execution argument, while presentation and higher-level LLM concerns
* higher-level LLM concerns, not safe-retrieval inputs. * belong outside safe retrieval.
*/ */
export interface WebFetchRequest { export interface WebFetchRequest {
readonly url: string readonly url: string
readonly timeoutMs?: number
} }
/** /**
@@ -88,8 +70,6 @@ export interface WebFetchRequest {
* represent the resource. * represent the resource.
*/ */
export interface WebFetchResult { 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). */ /** The final URL after allowed redirects (the request URL is in the request). */
readonly url: string readonly url: string
/** HTTP status code of the fetched response. */ /** HTTP status code of the fetched response. */
@@ -113,18 +93,6 @@ export type WebFetchBody =
| { readonly kind: 'html'; readonly content: string } | { readonly kind: 'html'; readonly content: string }
| { readonly kind: 'text'; 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`. * A search-capable backend. Registered with `ctx.web.registerSearchProvider`.
* `id` is a stable string, unique within the search capability kind. * `id` is a stable string, unique within the search capability kind.
@@ -132,9 +100,9 @@ export type WebProviderStatus =
export interface WebSearchProvider { export interface WebSearchProvider {
readonly id: string readonly id: string
/** Cheap local usability check; must not make network calls. */ /** Cheap local usability check; must not make network calls. */
status(): WebProviderStatus available(): boolean
/** Run one search; honor `exec.signal` for cancellation. */ /** Run one search; honor `signal` for cancellation. */
search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult> search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
} }
/** /**
@@ -144,9 +112,9 @@ export interface WebSearchProvider {
export interface WebFetchProvider { export interface WebFetchProvider {
readonly id: string readonly id: string
/** Cheap local usability check; must not make network calls. */ /** Cheap local usability check; must not make network calls. */
status(): WebProviderStatus available(): boolean
/** Retrieve one URL; honor `exec.signal` for cancellation. */ /** Retrieve one URL; honor `signal` for cancellation. */
fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult> fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
} }
/** /**

View File

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

View File

@@ -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": "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": "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": "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": "WorkflowStartRequest", "source": "packages/workflow/workflow/src/types.ts" },
{ "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowMeta", "source": "packages/workflow/workflow/src/types.ts" }, { "doc": "docs/core-data-structures/workflow.md", "symbol": "WorkflowMeta", "source": "packages/workflow/workflow/src/types.ts" },