refactor: prune unused web seam fields
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user