Merge origin/master into timeout-design

Resolve conflicts from master's catalog/doc refactors landing alongside the
tool-call timeout work:
- knip.json: keep both new workspace entries (util/timeout + support/acp-snapshot).
- tool-web/src/fetch.ts: keep the timeout_ms removal, adopt master's richer
  JSDoc @param/@returns style on parseFetchArgs/presentFetchCall.
- tools/README.md: keep the tools/execute pipeline wording, adopt master's
  flattened docs/tool-catalog.md path.
- Regenerate every generated doc (cordis-catalog, tool-catalog, config-catalog,
  doc-graphs, module-graph) so they carry both master's changes and the
  tools/execute event + timeout-policy package.
- Add @param/@returns to toolTimeoutResult for master's new verify-export-jsdoc gate.
This commit is contained in:
Dudu-0223
2026-07-08 11:18:27 +08:00
295 changed files with 10849 additions and 1098 deletions

View File

@@ -20,13 +20,26 @@ import { assertNever } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { htmlToMarkdown } from './html.ts'
/** Validate value constraints the schema DSL can't express. */
/**
* Validate value constraints the schema DSL can't express: a non-blank `url`.
* Throws a plain `Error` otherwise. No timeout parameter — the tool-call budget
* is deployment policy (`@deepseek-ai/dsh-timeout-policy`), not a model argument.
*
* @param args - the schema-validated `web_fetch` arguments.
* @returns the arguments as the seam's request fields.
*/
export function parseFetchArgs(args: { url: string }): { url: string } {
if (args.url.trim().length === 0) throw new Error('url must be a non-empty string')
return { url: args.url }
}
/** Render a fetched body to model-facing markdown text. */
/**
* Render a fetched body to model-facing markdown text.
*
* @param body - the decoded body; `html` is converted via
* {@link htmlToMarkdown}, `text` passes through verbatim.
* @returns the text for the tool's output block.
*/
export function renderBody(body: WebFetchBody): string {
switch (body.kind) {
case 'html':
@@ -39,19 +52,35 @@ export function renderBody(body: WebFetchBody): string {
}
}
/** Format a fetch result as one model-facing text block. */
/**
* Format a fetch result as one model-facing text block.
*
* @param result - the seam's fetch outcome.
* @returns a `Fetched <url> (HTTP <status>)` header, the rendered body, and a
* fetch-something-narrower notice when the provider truncated the content.
*/
export function formatFetchOutput(result: WebFetchResult): string {
const header = `Fetched ${result.url} (HTTP ${result.statusCode})`
const footer = result.truncated ? '\n\n(Content truncated. Fetch a more specific URL or section for the full text.)' : ''
return `${header}\n\n${renderBody(result.body)}${footer}`
}
/** Pending-call presentation: a fetch card titled by the URL. */
/**
* Pending-call presentation: a fetch card titled by the URL.
*
* @param args - the raw tool arguments; only `url` feeds the view.
* @returns the generic card view (`kind: 'fetch'`) shown while the call runs.
*/
export function presentFetchCall(args: { url: string }): GenericCallView {
return { card: 'generic', title: args.url, kind: 'fetch', rawInput: args.url }
}
/** Register the `web_fetch` tool and its system-prompt guidance. */
/**
* Register the `web_fetch` tool and its system-prompt guidance.
*
* @param ctx - context whose `tools` and `systemPrompt` registries receive the
* registrations; both are effect-scoped and unregister on plugin dispose.
*/
export function applyWebFetchTool(ctx: Context): void {
ctx.systemPrompt.section({
name: 'tool:web_fetch',

View File

@@ -44,6 +44,10 @@ function safeFromCodePoint(code: number, fallback: string): string {
* Convert an HTML document to a readable markdown-ish text approximation.
* Best-effort and lossy by design — fidelity is the job of a future heavier
* converter, not this fallback.
*
* @param html - the raw HTML source.
* @returns plain text with markdown headings, list bullets, and links;
* whitespace collapsed to at most one blank line and trimmed.
*/
export function htmlToMarkdown(html: string): string {
let text = html

View File

@@ -33,6 +33,7 @@ export const name = 'tool-web'
/** Services required by the web tool suite. */
export const inject = ['tools', 'web', 'systemPrompt']
/** Plugin config: which web tools to register, and the `web_search` source cap. */
export interface Config {
/** Register `web_search`. Defaults to true. */
search?: boolean

View File

@@ -20,7 +20,13 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
*/
export const WEB_SEARCH_MAX_RESULTS = 8
/** Validate value constraints the schema DSL can't express. */
/**
* Validate value constraints the schema DSL can't express: a non-blank
* `query`. Throws a plain `Error` otherwise.
*
* @param args - the schema-validated `web_search` arguments.
* @returns the accepted arguments, passed through unchanged.
*/
export function parseSearchArgs(args: { query: string }): { query: string } {
if (args.query.trim().length === 0) throw new Error('query must be a non-empty string')
return { query: args.query }
@@ -38,7 +44,14 @@ function sourceLabel(url: string, title: string | undefined): string {
}
}
/** Format a search result as one model-facing text block. */
/**
* Format a search result as one model-facing text block.
*
* @param result - the seam's search outcome.
* @returns the provider answer (when any), a markdown source list with snippet
* and date metadata (or `No results found.`), a refine-the-query note when
* truncated, and a standing cite-your-sources instruction.
*/
export function formatSearchOutput(result: WebSearchResult): string {
const parts: string[] = []
if (result.content !== undefined && result.content.length > 0) parts.push(result.content)
@@ -62,12 +75,24 @@ export function formatSearchOutput(result: WebSearchResult): string {
return parts.join('\n\n')
}
/** Pending-call presentation: a search card titled by the query. */
/**
* Pending-call presentation: a search card titled by the query.
*
* @param args - the raw tool arguments; only `query` feeds the view.
* @returns the generic card view (`kind: 'search'`) shown while the call runs.
*/
export function presentSearchCall(args: { query: string }): GenericCallView {
return { card: 'generic', title: args.query, kind: 'search', rawInput: args.query }
}
/** Register the `web_search` tool and its system-prompt guidance. `maxResults` is the deployment's source cap. */
/**
* Register the `web_search` tool and its system-prompt guidance.
*
* @param ctx - context whose `tools` and `systemPrompt` registries receive the
* registrations; both are effect-scoped and unregister on plugin dispose.
* @param maxResults - the deployment's source cap, sent as every seam
* request's `maxResults`.
*/
export function applyWebSearchTool(ctx: Context, maxResults: number): void {
ctx.systemPrompt.section({
name: 'tool:web_search',

View File

@@ -30,6 +30,7 @@ export const name = 'web-fetch-local'
/** The web seam this provider registers into. */
export const inject = ['web']
/** Plugin config: the provider's transport and size limits plus its `User-Agent` (all defaulted). */
export interface Config {
/** Maximum accepted request URL length. */
maxUrlLength?: number

View File

@@ -16,6 +16,10 @@ export type FetchableKind = 'html' | 'text'
* enforces before any network access: http(s) only, no embedded credentials,
* bounded length. Returns the parsed `URL`. Throws {@link WebError} otherwise.
* (SSRF / private-network blocking is deferred — see the package RFC.)
*
* @param input - the raw URL string from the fetch request.
* @param maxUrlLength - inclusive upper bound on `input`'s length.
* @returns the parsed `URL`.
*/
export function validateFetchUrl(input: string, maxUrlLength: number): URL {
if (input.length > maxUrlLength) {
@@ -40,6 +44,10 @@ export function validateFetchUrl(input: string, maxUrlLength: number): URL {
* Two URLs are same-origin when scheme, hostname, and port match. A redirect
* that crosses origins is refused so each new origin requires a fresh tool call
* (and thus a fresh provider/permission decision).
*
* @param a - one of the two URLs to compare.
* @param b - the other URL to compare.
* @returns true when `a` and `b` share scheme, hostname, and port.
*/
export function isSameOrigin(a: URL, b: URL): boolean {
return a.protocol === b.protocol && a.hostname === b.hostname && a.port === b.port
@@ -49,6 +57,10 @@ export function isSameOrigin(a: URL, b: URL): boolean {
* Classify a response `Content-Type` into a decodable body kind, or `undefined`
* for an unsupported (e.g. binary) type. `text/html` and `application/xhtml+xml`
* are `html`; other `text/*` plus a few structured text types are `text`.
*
* @param contentType - the raw `Content-Type` header, or `null` when the
* response carries none (unsupported).
* @returns the decodable kind, or `undefined` for an unsupported type.
*/
export function classifyContentType(contentType: string | null): FetchableKind | undefined {
const mime = (contentType ?? '').replace(/;.*$/s, '').trim().toLowerCase()
@@ -63,6 +75,10 @@ export function classifyContentType(contentType: string | null): FetchableKind |
* or `undefined` when absent. The provider feeds this label to `TextDecoder`
* so a non-UTF-8 response is decoded with its declared encoding rather than
* silently mangled into replacement characters.
*
* @param contentType - the raw `Content-Type` header, or `null` when the
* response carries none.
* @returns the lower-cased charset label, or `undefined` when none is declared.
*/
export function parseCharset(contentType: string | null): string | undefined {
const match = /;\s*charset\s*=\s*"?([^";]+)"?/i.exec(contentType ?? '')
@@ -74,6 +90,10 @@ export function parseCharset(contentType: string | null): string | undefined {
* none is declared. Throws {@link WebError} `WEB_UNSUPPORTED_CONTENT_TYPE` when
* the label is present but not a charset `TextDecoder` recognizes — better to
* fail loudly than return mojibake.
*
* @param charset - the declared charset label (from {@link parseCharset}), or
* `undefined` to default to UTF-8.
* @returns a decoder for the declared (or defaulted) encoding.
*/
export function decoderForCharset(charset: string | undefined): TextDecoder {
if (charset === undefined) return new TextDecoder('utf-8')

View File

@@ -1,6 +1,6 @@
/**
* `LocalFetchProvider`: a `WebFetchProvider` that retrieves a concrete public
* HTTP(S) URL with the platform-native `fetch` (Node 24) and returns a status
* HTTP(S) URL with platform-native `fetch` at the repo's Node floor and returns a status
* code plus bounded decoded content. It owns SAFE RESOURCE RETRIEVAL — URL
* validation, redirect policy, timeout, abort, byte caps, charset decoding,
* content-type classification, binary rejection — but NOT presentation

View File

@@ -44,6 +44,7 @@ export const name = 'web-search-deepseek'
/** The web seam this provider registers into. */
export const inject = ['web']
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
export interface Config {
/** DeepSeek API key. Falls back to `$DEEPSEEK_API_KEY`. Empty → unavailable. */
apiKey?: string

View File

@@ -12,7 +12,7 @@
* `web_search_tool_result` block (native search did not trigger), it throws
* `WEB_PROVIDER_ERROR` rather than degrading to prose-scraping.
*
* Network requests use platform-native `fetch` (Node 24), mirroring
* Network requests use platform-native `fetch` at the repo's Node floor, mirroring
* `@deepseek-ai/dsh-llm-deepseek`'s adapter — not a cordis HTTP-client service.
* The Anthropic wire shape is a provider-private detail and does NOT make this
* provider depend on `ctx.llm`.
@@ -62,6 +62,7 @@ export const DEEPSEEK_DEFAULT_MAX_USES = 5
/** Attribution header sent on every request. Bump with the package version. */
const USER_AGENT = 'deepseek-harness/0.0.1'
/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
export interface DeepSeekSearchProviderOptions {
/** DeepSeek API key. Empty/absent → `status()` reports `missing-credential`. */
apiKey: string
@@ -82,6 +83,9 @@ export interface DeepSeekSearchProviderOptions {
* is the snippet surface: Anthropic `web_search_result` items carry
* `url`/`title`/`page_age` but typically NO inline snippet — the excerpt lives
* in a separate `text` block's citation, keyed by `url` (first occurrence wins).
*
* @param blocks - the response's content blocks; non-`text` blocks are skipped.
* @returns the `url → cited_text` map (empty when no citations are present).
*/
export function citationSnippets(blocks: readonly ContentBlock[]): Map<string, string> {
const map = new Map<string, string>()
@@ -106,6 +110,10 @@ export function citationSnippets(blocks: readonly ContentBlock[]): Map<string, s
* Throws `WEB_PROVIDER_ERROR` (strict mode) when no `web_search_tool_result`
* block is present — native search did not trigger, and prose-scraping is not a
* fallback.
*
* @param query - the original request query, echoed on the result.
* @param response - the parsed Messages response body.
* @returns the normalized result with deduped, snippet-joined sources.
*/
export function mapAnthropicResponse(query: string, response: AnthropicResponse): WebSearchResult {
const blocks = response.content ?? []

View File

@@ -35,6 +35,7 @@ export const name = 'web-search-exa'
/** The web seam this provider registers into. */
export const inject = ['web']
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
export interface Config {
/** Exa API key. Falls back to `$EXA_API_KEY`. Empty → provider unavailable. */
apiKey?: string

View File

@@ -6,7 +6,7 @@
* `title`, the first highlight as `snippet`, and `publishedDate` as
* `publishedAt`.
*
* Network requests use platform-native `fetch` (Node 24), mirroring
* Network requests use platform-native `fetch` at the repo's Node floor, mirroring
* `@deepseek-ai/dsh-llm-deepseek`'s adapter — not a cordis HTTP-client service.
*
* @module @deepseek-ai/dsh-web-search-exa/provider
@@ -37,6 +37,7 @@ export const EXA_DEFAULT_HIGHLIGHTS_PER_RESULT = 1
/** Attribution header sent on every request. Bump with the package version. */
const USER_AGENT = 'deepseek-harness/0.0.1'
/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
export interface ExaSearchProviderOptions {
/** Exa API key. Empty/absent → `status()` reports `missing-credential`. */
apiKey: string
@@ -54,6 +55,10 @@ export interface ExaSearchProviderOptions {
* Map one Exa result to a normalized source, or `undefined` when it carries no
* portable snippet (an entry with no highlight is dropped — the seam has no
* other field to derive a snippet from, and inventing one would lie).
*
* @param result - one entry of Exa's `results[]`.
* @returns the normalized source, or `undefined` when the entry has no
* non-blank highlight.
*/
export function mapExaResult(result: ExaResult): WebSearchSource | undefined {
const snippet = result.highlights?.find(highlight => highlight.trim().length > 0)
@@ -66,7 +71,14 @@ 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.
* @returns the normalized result; snippet-less entries are dropped
* ({@link mapExaResult}).
*/
export function mapExaResponse(query: string, response: ExaSearchResponse): WebSearchResult {
const sources = (response.results ?? [])
.map(mapExaResult)

View File

@@ -29,6 +29,7 @@ export const name = 'web-search-perplexity'
/** The web seam this provider registers into. */
export const inject = ['web']
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
export interface Config {
/** Perplexity API key. Falls back to `$PERPLEXITY_API_KEY`. Empty → unavailable. */
apiKey?: string

View File

@@ -5,7 +5,7 @@
* structured `search_results[]` for `sources[]`, falling back to the URL-only
* `citations[]` when `search_results` is absent.
*
* Network requests use platform-native `fetch` (Node 24), mirroring
* Network requests use platform-native `fetch` at the repo's Node floor, mirroring
* `@deepseek-ai/dsh-llm-deepseek`'s adapter. The OpenAI-compatible request shape
* is a provider-private detail and does NOT make this provider depend on
* `ctx.llm`.
@@ -41,6 +41,7 @@ export type PerplexityRecency = 'day' | 'week' | 'month' | 'year'
/** Attribution header sent on every request. Bump with the package version. */
const USER_AGENT = 'deepseek-harness/0.0.1'
/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */
export interface PerplexitySearchProviderOptions {
/** Perplexity API key. Empty/absent → `status()` reports `missing-credential`. */
apiKey: string
@@ -54,7 +55,12 @@ export interface PerplexitySearchProviderOptions {
searchRecency?: PerplexityRecency
}
/** Map one structured Perplexity search result to a normalized source. */
/**
* Map one structured Perplexity search result to a normalized source.
*
* @param result - one entry of the response's `search_results[]`.
* @returns the normalized source; blank fields are omitted rather than set empty.
*/
export function mapPerplexityResult(result: PerplexitySearchResult): WebSearchSource {
return {
url: result.url,
@@ -68,6 +74,10 @@ export function mapPerplexityResult(result: PerplexitySearchResult): WebSearchSo
* Map a Perplexity response envelope to a normalized search result. Prefers
* structured `search_results[]`; falls back to URL-only `citations[]` (those
* sources carry just a `url`) only when `search_results` is absent.
*
* @param query - the original request query, echoed on the result.
* @param response - the parsed chat-completions response body.
* @returns the normalized result; `content` is omitted when the answer is empty.
*/
export function mapPerplexityResponse(query: string, response: PerplexityResponse): WebSearchResult {
const content = response.choices?.[0]?.message?.content