Merge remote-tracking branch 'origin/master' into codex/truncated-design

# Conflicts:
#	docs/event-producer-consumer.md
#	docs/module-graph.md
#	docs/rfc/INDEX.md
#	knip.json
#	packages/README.md
#	scripts/gen-module-graph.ts
#	tsconfig.base.json
#	tsconfig.build.json
#	tsconfig.json
This commit is contained in:
Dudu-0223
2026-07-09 12:54:30 +08:00
404 changed files with 22675 additions and 7822 deletions

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-tool-web
The model-facing web tool suite — `web_search` and `web_fetch` — over the [web capability seam](../web/README.md) (`ctx.web`). It owns model-facing concerns only: tool names, JSON schemas, snake_case argument names, prompt sections, the result-count bound, result formatting, HTML→markdown presentation, and `presentCall`. All web access goes through `ctx.web`; this package never imports a concrete provider.
The model-facing web tool suite — `web_search` and `web_fetch` — over the [web capability seam](../web/README.md) (`ctx.web`). It owns model-facing concerns only: tool names, JSON schemas, snake_case argument names, prompt sections, the result-count bound, result formatting, HTML→markdown presentation, and `presentCall`. All web access goes through `ctx.web`; this package never imports a concrete provider. Neither tool exposes a model-facing timeout — each tool's cooperative tool-call budget is declared here via config (`fetchTimeoutMs`/`searchTimeoutMs`, attached as `ToolDefinition.timeoutMs`) and enforced by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md) (a `tools/execute` wrapper); each tool just forwards `exec.signal` to the seam.
Each tool is registered independently; a product that wants only one disables the other via config (`{ search: false }` / `{ fetch: false }`).
@@ -9,7 +9,7 @@ Each tool is registered independently; a product that wants only one disables th
| Tool | Args | Behavior |
|---|---|---|
| `web_search` | `query` (string) | Discovery. Returns an optional answer plus source URLs. `max_results` is **not** model-facing — the tool sets the bound (the `searchMaxResults` config, default 8) and passes it to the seam. |
| `web_fetch` | `url` (string), `timeout_ms` (number, optional) | Retrieves a specific URL. HTML bodies are rendered to markdown-ish text; text bodies pass through. A non-2xx status is reported, not an error. |
| `web_fetch` | `url` (string) | Retrieves a specific URL. HTML bodies are rendered to markdown-ish text; text bodies pass through. A non-2xx status is reported, not an error. The tool-call timeout is deployment policy (`dsh-timeout-policy`), not a model argument. |
## Config
@@ -18,6 +18,10 @@ Each tool is registered independently; a product that wants only one disables th
| `search` | `true` | Register `web_search`. |
| `fetch` | `true` | Register `web_fetch`. |
| `searchMaxResults` | `8` | Upper bound on sources returned by one `web_search` call (the seam truncates a longer provider list and flags it). |
| `fetchTimeoutMs` | `30000` | Cooperative tool-call timeout budget (ms) for `web_fetch`. |
| `searchTimeoutMs` | `30000` | Cooperative tool-call timeout budget (ms) for `web_search`. |
`fetchTimeoutMs`/`searchTimeoutMs` declare each tool's cooperative timeout budget (attached as `ToolDefinition.timeoutMs`), enforced by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md); the model-facing schema exposes no timeout argument.
```yaml
- id: tool-web

View File

@@ -39,6 +39,7 @@
"@deepseek-ai/dsh-spill-policy": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-timeout-policy": "workspace:^",
"@deepseek-ai/dsh-web": "workspace:^",
"@deepseek-ai/dsh-web-fetch-local": "workspace:^",
"@deepseek-ai/dsh-web-search-exa": "workspace:^",

View File

@@ -3,6 +3,13 @@
* Execution goes through `ctx.web` — this module owns the model-facing schema,
* argument validation, and PRESENTATION (HTML→markdown, truncation formatting),
* while the fetch provider owns safe retrieval (transport, redirects, caps).
*
* The model-facing schema exposes NO timeout knob: the tool-call budget is
* deployment policy DECLARED via this package's `fetchTimeoutMs` config (attached
* as `ToolDefinition.timeoutMs`) and ENFORCED by `@deepseek-ai/dsh-timeout-policy`
* (a `tools/execute` wrapper), matching the reference-agent `WebFetch` shape. This
* tool just forwards the (possibly deadline-derived) `exec.signal` to `ctx.web`;
* the provider keeps its own timeout only as a resource backstop for direct callers.
*/
import type { Context } from 'cordis'
@@ -14,16 +21,27 @@ 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. */
export function parseFetchArgs(args: { url: string; timeout_ms?: number }): { url: string; timeoutMs?: number } {
/**
* 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 declared via `fetchTimeoutMs` config and enforced by
* `@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')
if (args.timeout_ms !== undefined && (!Number.isFinite(args.timeout_ms) || args.timeout_ms <= 0)) {
throw new Error('timeout_ms must be a positive number')
}
return { url: args.url, ...args.timeout_ms !== undefined ? { timeoutMs: args.timeout_ms } : {} }
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':
@@ -36,20 +54,38 @@ 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. */
export function presentFetchCall(args: { url: string; timeout_ms?: number }): GenericCallView {
/**
* 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. */
export function applyWebFetchTool(ctx: Context): void {
/**
* 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.
* @param timeoutMs - the cooperative tool-call budget (ms) attached as the tool's
* `ToolDefinition.timeoutMs` for `@deepseek-ai/dsh-timeout-policy` to enforce.
*/
export function applyWebFetchTool(ctx: Context, timeoutMs: number): void {
ctx.systemPrompt.section({
name: 'tool:web_fetch',
order: 111,
@@ -61,12 +97,12 @@ export function applyWebFetchTool(ctx: Context): void {
description: 'Fetch the content of a specific HTTP(S) URL and return it decoded to text.',
parameters: {
url: { type: 'string', required: true, description: 'The HTTP(S) URL to fetch.' },
timeout_ms: { type: 'number', description: 'Optional fetch timeout in milliseconds (capped by the provider).' },
},
timeoutMs,
async execute(args, exec): Promise<ContentBlock[]> {
const input = parseFetchArgs(args)
const result = await ctx.web.fetch(
{ url: input.url, ...input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {} },
{ url: input.url },
exec.signal ? { signal: exec.signal } : undefined,
)
return [{ type: 'text', text: formatFetchOutput(result) }]

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,10 @@ export const name = 'tool-web'
/** Services required by the web tool suite. */
export const inject = ['tools', 'web', 'systemPrompt']
/** Default cooperative tool-call timeout budget (ms) for the web tools. */
export const DEFAULT_WEB_TOOL_TIMEOUT_MS = 30_000
/** Plugin config: which web tools to register, the source cap, and per-tool budgets. */
export interface Config {
/** Register `web_search`. Defaults to true. */
search?: boolean
@@ -40,12 +44,18 @@ export interface Config {
fetch?: boolean
/** Upper bound on sources returned by one `web_search` call. */
searchMaxResults?: number
/** Cooperative timeout budget (ms) for `web_fetch`. Defaults to 30000. */
fetchTimeoutMs?: number
/** Cooperative timeout budget (ms) for `web_search`. Defaults to 30000. */
searchTimeoutMs?: number
}
export const Config: z<Config> = z.object({
search: z.boolean().default(true),
fetch: z.boolean().default(true),
searchMaxResults: z.number().default(WEB_SEARCH_MAX_RESULTS),
fetchTimeoutMs: z.number().default(DEFAULT_WEB_TOOL_TIMEOUT_MS),
searchTimeoutMs: z.number().default(DEFAULT_WEB_TOOL_TIMEOUT_MS),
})
/** The shape after schemastery applies its defaults to every field. */
@@ -60,7 +70,10 @@ function assertPositiveInteger(name: string, value: number): void {
/**
* Register the enabled web tools. `search`/`fetch` default to true; a product
* that wants only one disables the other in config. The tools' disposers are
* that wants only one disables the other in config. Each tool's cooperative
* timeout budget (`fetchTimeoutMs`/`searchTimeoutMs`, default 30000) is resolved
* here and attached to the tool as `ToolDefinition.timeoutMs` for
* `@deepseek-ai/dsh-timeout-policy` to enforce. The tools' disposers are
* fiber-scoped (the effect-based registries clean up on dispose), so no manual
* teardown is needed.
*/
@@ -68,6 +81,8 @@ export function apply(ctx: Context, config: Config): void {
// schemastery (Config) has already filled every defaulted field.
const resolved = config as ResolvedConfig
assertPositiveInteger('searchMaxResults', resolved.searchMaxResults)
if (resolved.search) applyWebSearchTool(ctx, resolved.searchMaxResults)
if (resolved.fetch) applyWebFetchTool(ctx)
assertPositiveInteger('fetchTimeoutMs', resolved.fetchTimeoutMs)
assertPositiveInteger('searchTimeoutMs', resolved.searchTimeoutMs)
if (resolved.search) applyWebSearchTool(ctx, resolved.searchMaxResults, resolved.searchTimeoutMs)
if (resolved.fetch) applyWebFetchTool(ctx, resolved.fetchTimeoutMs)
}

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,13 +75,27 @@ 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. */
export function applyWebSearchTool(ctx: Context, maxResults: number): void {
/**
* 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`.
* @param timeoutMs - the cooperative tool-call budget (ms) attached as the tool's
* `ToolDefinition.timeoutMs` for `@deepseek-ai/dsh-timeout-policy` to enforce.
*/
export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs: number): void {
ctx.systemPrompt.section({
name: 'tool:web_search',
order: 110,
@@ -81,6 +108,7 @@ export function applyWebSearchTool(ctx: Context, maxResults: number): void {
parameters: {
query: { type: 'string', required: true, description: 'The search query.' },
},
timeoutMs,
async execute(args, exec): Promise<ContentBlock[]> {
const input = parseSearchArgs(args)
const result = await ctx.web.search(

View File

@@ -1,10 +1,11 @@
/**
* Integration: the real fetch backend (`dsh-web-fetch-local`) + a real search
* provider (`dsh-web-search-exa`) + the real seam (`dsh-web`) + the model tool
* (`dsh-tool-web`), exercised through `ctx.tools.execute()` — nothing bypasses
* the tool registry. Fetch hits a real loopback HTTP server (verifying the
* WORLD); search runs the real Exa provider over a stubbed global `fetch` (the
* network is the one boundary we mock).
* (`dsh-tool-web`) + the tool-call timeout policy (`dsh-timeout-policy`),
* exercised through `ctx.tools.execute()` — nothing bypasses the tool registry.
* Fetch hits a real loopback HTTP server (verifying the WORLD); search runs the
* real Exa provider over a stubbed global `fetch` (the network is the one
* boundary we mock).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -18,6 +19,7 @@ import WebService from '@deepseek-ai/dsh-web'
import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local'
import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa'
import * as ToolWeb from '@deepseek-ai/dsh-tool-web'
import * as TimeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
type Handler = (req: IncomingMessage, res: ServerResponse) => void
@@ -39,6 +41,11 @@ beforeEach(async () => {
await ctx.plugin(WebService, { searchProvider: WebSearchExa.EXA_PROVIDER_ID, fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID })
await ctx.plugin(WebFetchLocal, {})
await ctx.plugin(WebSearchExa, { apiKey: 'exa-key', baseURL: 'https://api.exa.test' })
// The shipped deployment shape: the tool-call budget is declared by tool-web
// config (default 30s, attached as ToolDefinition.timeoutMs) and enforced by
// the zero-config timeout-policy plugin, set above the provider backstop so the
// policy normally wins.
await ctx.plugin(TimeoutPolicy)
fiber = await ctx.plugin(ToolWeb)
})
@@ -96,3 +103,70 @@ describe('web_search integration over the real Exa provider', () => {
expect(out.content.map(b => b.text).join('')).toContain('[Result](https://result.test)')
})
})
describe('tool-call timeout policy over the migrated web tools', () => {
it('neither model schema exposes a timeout parameter after the migration', () => {
const byName = new Map(ctx.tools.schemas().map(s => [s.name, s]))
const fetchParams = byName.get('web_fetch')!.parameters as { properties: Record<string, unknown> }
const searchParams = byName.get('web_search')!.parameters as { properties: Record<string, unknown> }
expect(Object.keys(fetchParams.properties)).toEqual(['url'])
expect('timeout_ms' in fetchParams.properties).toBe(false)
expect(Object.keys(searchParams.properties)).toEqual(['query'])
})
})
describe('tool-call timeout returns TOOL_TIMEOUT (deadline wins over a slow fetch)', () => {
let slowServer: Server
let slowBase: string
let openSockets: ServerResponse[]
let tctx: Context
let tfiber: Awaited<ReturnType<Context['plugin']>>
beforeEach(async () => {
// A server that never responds: it holds the connection open until the
// client aborts. The cooperative deadline (via exec.signal → the fetch
// provider → undici) is what ends the call.
openSockets = []
slowServer = createServer((_req, res) => { openSockets.push(res) })
await new Promise<void>(resolve => slowServer.listen(0, '127.0.0.1', resolve))
slowBase = `http://127.0.0.1:${(slowServer.address() as AddressInfo).port}`
tctx = new Context()
await tctx.plugin(SystemPrompt)
await tctx.plugin(ToolRegistry)
await tctx.plugin(WebService, { fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID })
// Provider backstop well ABOVE the tool-call budget, so the policy wins.
await tctx.plugin(WebFetchLocal, { timeoutMs: 30_000, maxTimeoutMs: 60_000 })
await tctx.plugin(TimeoutPolicy)
// The tool-call budget is declared by tool-web config, enforced by the policy.
tfiber = await tctx.plugin(ToolWeb, { fetchTimeoutMs: 50 })
})
afterEach(async () => {
for (const res of openSockets) res.destroy()
await tfiber.dispose()
await new Promise<void>(resolve => slowServer.close(() => { resolve() }))
})
it('returns a structured TOOL_TIMEOUT (not the provider WEB_FETCH_TIMEOUT) when the tool-call budget wins', async () => {
const out = await tctx.tools.execute({ callId: CallId('slow-1'), name: 'web_fetch', arguments: { url: slowBase } })
expect(out.isError).toBe(true)
// The outer tool-call deadline won: TOOL_TIMEOUT, owned by dsh-timeout-policy,
// NOT the provider's own WEB_FETCH_TIMEOUT (its 30s backstop never fired).
expect(out.error?.code).toBe('TOOL_TIMEOUT')
const text = out.content.map(b => (b.type === 'text' ? b.text : '')).join('')
expect(text).toContain('timed out after 50ms')
})
it('the provider backstop still protects a DIRECT ctx.web.fetch() call (no tool-call policy in that path)', async () => {
// A direct seam caller does not go through tools/execute, so the tool-call
// policy never applies; the provider's OWN timeout is the only budget. A
// short per-request hint proves the provider backstop is intact and classifies
// as WEB_FETCH_TIMEOUT (the provider-owned code), never TOOL_TIMEOUT.
const err = await tctx.web.fetch({ url: slowBase, timeoutMs: 50 }).then(
() => undefined,
(e: unknown) => e as { code?: string },
)
expect(err?.code).toBe('WEB_FETCH_TIMEOUT')
})
})

View File

@@ -110,10 +110,9 @@ describe('fetch formatting', () => {
expect(renderBody({ kind: 'html', content: '<p>y</p>' })).toBe('y')
})
it('validates url and timeout', () => {
it('validates url (non-empty), no timeout parameter', () => {
expect(() => parseFetchArgs({ url: ' ' })).toThrow('non-empty')
expect(() => parseFetchArgs({ url: 'https://a.test', timeout_ms: -1 })).toThrow('positive')
expect(parseFetchArgs({ url: 'https://a.test', timeout_ms: 5 })).toEqual({ url: 'https://a.test', timeoutMs: 5 })
expect(parseFetchArgs({ url: 'https://a.test' })).toEqual({ url: 'https://a.test' })
})
it('presents a fetch call as a fetch-kind card titled by the url', () => {
@@ -249,7 +248,7 @@ describe('tool-web execution through the real registry', () => {
expect('default' in ToolWeb).toBe(false)
})
it('executes web_fetch, forwarding timeout_ms 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 fetchProvider = {
id: 'stub-fetch',
@@ -262,13 +261,35 @@ describe('tool-web execution through the real registry', () => {
}
const { ctx, fiber } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider })
const controller = new AbortController()
const out = await ctx.tools.execute({ callId: CallId('fetch-1'), name: 'web_fetch', arguments: { url: 'https://a.test', timeout_ms: 1234 }, signal: controller.signal })
const out = await ctx.tools.execute({ callId: CallId('fetch-1'), name: 'web_fetch', arguments: { url: 'https://a.test' }, signal: controller.signal })
expect(out.isError).toBe(false)
expect(seen.request).toEqual({ url: 'https://a.test', timeoutMs: 1234 })
// The model schema exposes no timeout: the tool forwards only the url; the
// tool-call budget is owned by dsh-timeout-policy over exec.signal.
expect(seen.request).toEqual({ url: 'https://a.test' })
expect(seen.signal).toBe(controller.signal)
await fiber.dispose()
})
it('executes web_fetch with no caller signal (forwards undefined to the seam)', async () => {
const seen: { signal?: AbortSignal | undefined; passedExec?: boolean } = {}
const fetchProvider = {
id: 'stub-fetch',
status: () => available,
fetch: (request: { url: string }, exec?: { signal?: AbortSignal }) => {
seen.passedExec = exec !== undefined
seen.signal = exec?.signal
return Promise.resolve({ providerId: 'stub-fetch', url: request.url, statusCode: 200, body: { kind: 'text' as const, content: 'ok' }, truncated: false })
},
}
const { ctx, fiber } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider })
// No signal on the execution: the tool passes `undefined` (not `{ signal: undefined }`).
const out = await ctx.tools.execute({ callId: CallId('fetch-2'), name: 'web_fetch', arguments: { url: 'https://a.test' } })
expect(out.isError).toBe(false)
expect(seen.passedExec).toBe(false)
expect(seen.signal).toBeUndefined()
await fiber.dispose()
})
it('executes web_search, forwarding the abort signal to the seam', async () => {
const seen: { signal?: AbortSignal | undefined } = {}
const provider: WebSearchProvider = {
@@ -328,3 +349,31 @@ describe('searchMaxResults is plugin config', () => {
.rejects.toThrow(/tool-web: searchMaxResults must be a positive integer/)
})
})
describe('tool-call timeout budget is plugin config', () => {
it('attaches the default 30s budget to web_fetch and web_search', async () => {
const { fiber, ctx } = await mountTools()
expect(ctx.tools.get('web_fetch')?.timeoutMs).toBe(30_000)
expect(ctx.tools.get('web_search')?.timeoutMs).toBe(30_000)
await fiber.dispose()
})
it('honors per-tool timeout overrides from config', async () => {
const { fiber, ctx } = await mountTools({ config: { fetchTimeoutMs: 60_000, searchTimeoutMs: 10_000 } })
expect(ctx.tools.get('web_fetch')?.timeoutMs).toBe(60_000)
expect(ctx.tools.get('web_search')?.timeoutMs).toBe(10_000)
await fiber.dispose()
})
it.each([
['fetchTimeoutMs', { fetchTimeoutMs: 0 }],
['searchTimeoutMs', { searchTimeoutMs: -5 }],
])('rejects a non-positive-integer %s at load', async (key, config) => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(WebService, {})
await expect(ctx.plugin(ToolWeb, config))
.rejects.toThrow(new RegExp(`tool-web: ${key} must be a positive integer`))
})
})

View File

@@ -12,6 +12,7 @@
{ "path": "../../llm/llm" },
{ "path": "../../core/tools" },
{ "path": "../../core/system-prompt" },
{ "path": "../../timeout/timeout-policy" },
{ "path": "../web" }
]
}

View File

@@ -6,7 +6,9 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i
## Responsibility split
The provider owns **safe resource retrieval**: URL validation, HTTP transport, redirect policy, 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 — it is NOT the model-facing tool-call budget. The tool-call budget for `web_fetch` is deployment policy owned by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md), which arms a per-call deadline on `exec.signal`. A shipped web-tool deployment sets the provider backstop **above** the `tool-timeout` budget, so the tool-call policy normally wins for model calls (returning `TOOL_TIMEOUT`); when the outer deadline signal reaches this provider first, it classifies as `WEB_ABORTED` and the outer wrapper replaces the result with `TOOL_TIMEOUT`. The provider's own `WEB_FETCH_TIMEOUT` only fires for a direct seam caller whose own budget elapsed.
## Transport hygiene
@@ -24,8 +26,8 @@ The provider owns **safe resource retrieval**: URL validation, HTTP transport, r
| `maxUrlLength` | `2048` | Maximum accepted request URL length. |
| `maxResponseBytes` | `5_000_000` | Maximum response body size in bytes. |
| `maxBodyChars` | `100_000` | Maximum decoded body length in characters. |
| `timeoutMs` | `30_000` | Default fetch timeout. |
| `maxTimeoutMs` | `120_000` | Upper bound for a per-request timeout override. |
| `timeoutMs` | `30_000` | Default fetch timeout — a resource backstop for direct `ctx.web.fetch()` callers, not the model-facing tool-call budget (that is `dsh-timeout-policy`). |
| `maxTimeoutMs` | `120_000` | Upper bound for a per-request timeout override (direct callers). |
| `maxRedirects` | `5` | Maximum same-origin redirect hops (`0` follows none). |
| `userAgent` | `deepseek-harness/…` | `User-Agent` header. |

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