refactor: prune unused web seam fields

This commit is contained in:
Tianyi Cui
2026-07-14 04:17:38 +08:00
parent a0359bc4a9
commit 3ab35de64f
34 changed files with 228 additions and 344 deletions

View File

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

View File

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

View File

@@ -1,8 +1,7 @@
/**
* Vocabulary for the web capability seam (`ctx.web`): the search/fetch
* request/result shapes providers produce and consumers format, the provider
* status discriminant selection reads, the execution-control context, and the
* typed error taxonomy.
* request/result shapes providers produce and consumers format, provider
* availability, direct cancellation control, and the typed error taxonomy.
*
* These types are shared by every provider backend
* (`@deepseek-ai/dsh-web-search-exa`, `@deepseek-ai/dsh-web-search-perplexity`,
@@ -19,19 +18,6 @@
import { HarnessError } from '@deepseek-ai/dsh-llm'
/**
* Execution control threaded from the tool layer through the seam into a
* provider's network requests, stream readers, and expensive decoding. It is
* NOT business input: the first version carries only `signal` so `tool-web` can
* propagate turn cancellation, tool timeout, and agent disposal. It deliberately
* does NOT carry `ToolExecution`, which would make `dsh-web` depend on
* `dsh-tools`.
*/
export interface WebExecContext {
/** Abort signal a provider must honor for its network/decoding work. */
readonly signal?: AbortSignal
}
/**
* What one search-capable backend can return. The model-facing argument is just
* a query; `maxResults` is a `dsh-tool-web`-layer bound passed through unchanged
@@ -56,10 +42,6 @@ export interface WebSearchRequest {
* when it cut `sources[]` down to `maxResults`.
*/
export interface WebSearchResult {
/** Id of the provider that produced this result. */
readonly providerId: string
/** Echo of the query the provider answered. */
readonly query: string
/** Optional provider-generated answer text, search context, or summary. */
readonly content?: string
/** Citeable sources, already truncated to the request's `maxResults`. */
@@ -83,14 +65,13 @@ export interface WebSearchSource {
}
/**
* What one fetch-capable backend is asked to retrieve. `timeoutMs` is an
* optional positive hint the provider caps. The request deliberately omits
* `format`, `prompt`, and extraction controls — those are presentation or
* higher-level LLM concerns, not safe-retrieval inputs.
* What one fetch-capable backend is asked to retrieve. The request deliberately
* omits timeout, format, prompt, and extraction controls: cancellation is a
* direct execution argument, while presentation and higher-level LLM concerns
* belong outside safe retrieval.
*/
export interface WebFetchRequest {
readonly url: string
readonly timeoutMs?: number
}
/**
@@ -100,8 +81,6 @@ export interface WebFetchRequest {
* represent the resource.
*/
export interface WebFetchResult {
/** Id of the provider that produced this result. */
readonly providerId: string
/** The final URL after allowed redirects (the request URL is in the request). */
readonly url: string
/** HTTP status code of the fetched response. */
@@ -125,18 +104,6 @@ export type WebFetchBody =
| { readonly kind: 'html'; readonly content: string }
| { readonly kind: 'text'; readonly content: string }
/**
* Whether one concrete provider implementation is usable, by cheap local checks
* only (credential presence, parseable endpoint config). A provider `status()`
* must NOT make network calls. It is an input to execution-time selection, not
* a health system: `WebService.search()`/`fetch()` read it to pick a usable
* provider, and selection failure surfaces as the structured {@link WebError}
* codes callers route on.
*/
export type WebProviderStatus =
| { readonly available: true }
| { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' }
/**
* A search-capable backend. Registered with `ctx.web.registerSearchProvider`.
* `id` is a stable string, unique within the search capability kind.
@@ -144,9 +111,9 @@ export type WebProviderStatus =
export interface WebSearchProvider {
readonly id: string
/** Cheap local usability check; must not make network calls. */
status(): WebProviderStatus
/** Run one search; honor `exec.signal` for cancellation. */
search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult>
available(): boolean
/** Run one search; honor `signal` for cancellation. */
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
}
/**
@@ -156,9 +123,9 @@ export interface WebSearchProvider {
export interface WebFetchProvider {
readonly id: string
/** Cheap local usability check; must not make network calls. */
status(): WebProviderStatus
/** Retrieve one URL; honor `exec.signal` for cancellation. */
fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult>
available(): boolean
/** Retrieve one URL; honor `signal` for cancellation. */
fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
}
/**
@@ -178,12 +145,12 @@ export interface WebFetchProvider {
* - `WEB_PROVIDER_UNAVAILABLE`: no provider configured and none usable.
* - `WEB_PROVIDER_CONFIGURED_MISSING`: a configured id is not registered.
* - `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`: a configured id is registered but its
* `status()` reports unavailable.
* `available()` returns false.
* - `WEB_PROVIDER_AMBIGUOUS`: no id configured and multiple usable providers
* exist (selection refuses to pick by registration order).
* - `WEB_DUPLICATE_PROVIDER`: a registration-time programming error — an id is
* already registered for that capability kind.
* - `WEB_ABORTED`: the operation was aborted via `WebExecContext.signal`.
* - `WEB_ABORTED`: the operation was aborted via its optional signal.
* - `WEB_PROVIDER_ERROR`: catch-all for a provider's own failure surfaced
* through the seam, including network/transport failure (DNS, connection
* refused, TLS).

View File

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