Files
deepseek-harness/packages/web/tool-web/src/fetch.ts
Dudu-0223 8190016e2b feat(timeout): add tools/execute seam + tool-timeout policy plugin
Model-facing tool-call budgets were tangled into each capability's schema
(bash timeoutMs, web_fetch timeout_ms) with no shared home. Add a
tools/execute around-dispatch waterfall to dsh-tools whose base next() is
the dispatch-with-normalization thunk, and a new @deepseek-ai/dsh-timeout-policy
plugin (packages/timeout/) that arms a per-tool deadline on exec.signal and
returns a structured TOOL_TIMEOUT when it wins. Migrate web_fetch (drop the
model-facing timeout_ms) and web_search onto it; the fetch provider keeps its
timeout only as a resource backstop for direct callers. bash and hook command
execution keep BASH_TIMEOUT unchanged.

Named the plugin timeout-policy (not the RFC's tool-timeout) so it does not
trip the gen-tool-catalog packages/*/tool-* completeness guard, and replace
exec.signal by in-place mutation before next() since cordis waterfall next()
ignores passed arguments. RFC moved to implemented/ recording both deviations.
2026-07-08 10:10:37 +08:00

79 lines
3.4 KiB
TypeScript

/**
* The model-facing `web_fetch` tool: retrieve the content of a specific URL.
* 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 owned 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'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { WebFetchBody, WebFetchResult } from '@deepseek-ai/dsh-web'
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 }): { 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. */
export function renderBody(body: WebFetchBody): string {
switch (body.kind) {
case 'html':
return htmlToMarkdown(body.content)
case 'text':
return body.content
/* v8 ignore next 2 -- WebFetchBody is a closed union; this arm is unreachable and only makes adding a kind a compile error. */
default:
return assertNever(body, 'unhandled web fetch body kind')
}
}
/** Format a fetch result as one model-facing text block. */
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 }): 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 {
ctx.systemPrompt.section({
name: 'tool:web_fetch',
order: 111,
text: 'Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for example a result from web_search). It returns the page content decoded to text. Cite the URL as a markdown link when you use its content.',
})
ctx.tools.register(defineTool({
name: 'web_fetch',
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.' },
},
async execute(args, exec): Promise<ContentBlock[]> {
const input = parseFetchArgs(args)
const result = await ctx.web.fetch(
{ url: input.url },
exec.signal ? { signal: exec.signal } : undefined,
)
return [{ type: 'text', text: formatFetchOutput(result) }]
},
presentCall: presentFetchCall,
}))
}