2026-06-25 15:04:12 +08:00
/**
2026-07-13 23:27:00 +08:00
* The model-facing `web_fetch` tool. This module owns its schema, validation, and presentation;
* `ctx.web` owns retrieval. Timeout is deployment policy, not a model argument: config becomes
* `ToolDefinition.timeoutMs`, timeout policy enforces it, and this tool forwards the resulting
* signal. A provider timeout remains a backstop for direct seam callers.
2026-06-25 15:04:12 +08:00
*/
import type { Context } from 'cordis'
2026-07-27 00:22:13 +08:00
import TurndownService from 'turndown'
import { gfm } from '@joplin/turndown-plugin-gfm'
2026-06-25 15:04:12 +08:00
import { defineTool } from '@deepseek-ai/dsh-tools'
2026-07-03 22:52:18 +08:00
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
2026-06-25 15:04:12 +08:00
import type { WebFetchBody , WebFetchResult } from '@deepseek-ai/dsh-web'
import { assertNever } from '@deepseek-ai/dsh-llm'
import type { } from '@deepseek-ai/dsh-system-prompt'
2026-07-27 00:22:13 +08:00
/**
* The shared HTML→markdown converter: turndown over its bundled domino DOM,
* with GitHub-flavored tables/strikethrough (`@joplin/turndown-plugin-gfm`).
* The style options are fixed model-facing presentation (matching the repo's
* markdown conventions), not deployment tunables. `remove` drops non-content
* elements wholesale — turndown's default keeps their text. The instance is
* stateless across `turndown()` calls and safe to share.
*/
const turndown = new TurndownService ( {
headingStyle : 'atx' ,
codeBlockStyle : 'fenced' ,
bulletListMarker : '-' ,
} )
turndown . use ( gfm )
turndown . remove ( [ 'script' , 'style' , 'noscript' ] )
2026-06-25 15:04:12 +08:00
2026-07-06 22:09:30 +08:00
/**
2026-07-08 11:18:27 +08:00
* 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
2026-07-08 14:40:14 +08:00
* is deployment policy declared via `fetchTimeoutMs` config and enforced by
* `@deepseek-ai/dsh-timeout-policy`, not a model argument.
2026-07-06 22:09:30 +08:00
*
* @param args - the schema-validated `web_fetch` arguments.
2026-07-08 11:18:27 +08:00
* @returns the arguments as the seam's request fields.
2026-07-06 22:09:30 +08:00
*/
2026-07-08 10:06:07 +08:00
export function parseFetchArgs ( args : { url : string } ) : { url : string } {
2026-06-25 15:04:12 +08:00
if ( args . url . trim ( ) . length === 0 ) throw new Error ( 'url must be a non-empty string' )
2026-07-08 10:06:07 +08:00
return { url : args.url }
2026-06-25 15:04:12 +08:00
}
2026-07-27 12:41:36 +08:00
/**
* Nesting-depth ceiling above which HTML skips conversion and passes through
* raw. Conversion runs synchronously on the event loop, and unclosed-tag
* nesting makes domino's tree (and turndown's walk over it) superlinear —
* measured: depth 512 ≈ 0.15s, 2,000 ≈ 2s, 20,000 ≈ 5s — during which the
* cooperative `fetchTimeoutMs` timer cannot fire. Real pages nest a few dozen
* levels; 512 is far above content and far below weaponizable. A robustness
* invariant, not a tunable.
*/
const MAX_CONVERSION_DEPTH = 512
/** Elements that never take a closing tag, so they must not count toward nesting depth. */
const VOID_ELEMENTS = new Set ( [
'area' , 'base' , 'br' , 'col' , 'embed' , 'hr' , 'img' , 'input' ,
'link' , 'meta' , 'param' , 'source' , 'track' , 'wbr' ,
] )
/**
* Estimate the maximum element nesting depth of an HTML string with one linear
* tag scan. Overestimates when markup-like text sits inside `script`/`style`
* bodies or comments (the scan does not parse those), which can only cause a
* spurious raw-HTML fallback, never a missed bound.
*
* @param html - the decoded HTML body.
* @returns the deepest open-element count the scan reaches.
*/
export function htmlNestingDepth ( html : string ) : number {
let depth = 0
let max = 0
for ( const tag of html . matchAll ( /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)[^>]*?(\/?)>/g ) ) {
const [ , closing , rawName = '' , selfClosing ] = tag
const name = rawName . toLowerCase ( )
if ( VOID_ELEMENTS . has ( name ) || selfClosing === '/' ) continue
if ( closing === '/' ) {
if ( depth > 0 ) depth -= 1
} else {
depth += 1
if ( depth > max ) max = depth
}
}
return max
}
2026-07-06 22:09:30 +08:00
/**
* Render a fetched body to model-facing markdown text.
*
2026-07-27 00:22:13 +08:00
* @param body - the decoded body; `html` is converted via turndown, `text`
2026-07-27 12:41:36 +08:00
* passes through verbatim. HTML nested beyond {@link MAX_CONVERSION_DEPTH}
* skips conversion up front (the synchronous walk over such trees is
* superlinear and blocks the event loop past the cooperative timeout), and
* when turndown itself throws the raw HTML passes through instead — a
* degraded page beats an error for a body the provider already decoded.
2026-07-06 22:09:30 +08:00
* @returns the text for the tool's output block.
*/
2026-06-25 15:04:12 +08:00
export function renderBody ( body : WebFetchBody ) : string {
switch ( body . kind ) {
case 'html' :
2026-07-27 12:41:36 +08:00
if ( htmlNestingDepth ( body . content ) > MAX_CONVERSION_DEPTH ) return body . content
2026-07-27 00:22:13 +08:00
try {
return turndown . turndown ( body . content )
} catch {
2026-07-27 12:41:36 +08:00
// turndown's DOM walk recurses per element; malformed markup the depth
// scan cannot see can still throw RangeError. Provider errors stay
2026-07-27 00:22:13 +08:00
// structured WebErrors upstream; conversion failure downgrades to raw HTML.
return body . content
}
2026-06-25 15:04:12 +08:00
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' )
}
}
2026-07-27 12:41:36 +08:00
/** The truncation notice appended when the provider or the output cap cut content. */
const TRUNCATION_FOOTER = '\n\n(Content truncated. Fetch a more specific URL or section for the full text.)'
2026-07-06 22:09:30 +08:00
/**
2026-07-27 12:41:36 +08:00
* Format a fetch result as one model-facing text block, bounded as a whole.
* Markdown escaping can expand converted HTML (worst case ~2× the provider's
* body cap), so the bound applies here, where the complete output — header,
* rendered body, and footer — is known.
2026-07-06 22:09:30 +08:00
*
* @param result - the seam's fetch outcome.
2026-07-27 12:41:36 +08:00
* @param maxOutputChars - cap on the complete returned string; a cut body gets
* the same fetch-something-narrower notice as provider-side truncation.
2026-07-06 22:09:30 +08:00
* @returns a `Fetched <url> (HTTP <status>)` header, the rendered body, and a
2026-07-27 12:41:36 +08:00
* truncation notice when the provider or the cap cut the content.
2026-07-06 22:09:30 +08:00
*/
2026-07-27 12:41:36 +08:00
export function formatFetchOutput ( result : WebFetchResult , maxOutputChars : number ) : string {
const header = ` Fetched ${ result . url } (HTTP ${ result . statusCode } ) \ n \ n `
const body = renderBody ( result . body )
const full = ` ${ header } ${ body } ${ result . truncated ? TRUNCATION_FOOTER : '' } `
if ( full . length <= maxOutputChars ) return full
const budget = Math . max ( 0 , maxOutputChars - header . length - TRUNCATION_FOOTER . length )
return ` ${ header } ${ body . slice ( 0 , budget ) } ${ TRUNCATION_FOOTER } `
2026-06-25 15:04:12 +08:00
}
2026-07-06 22:09:30 +08:00
/**
* 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.
*/
2026-07-08 10:06:07 +08:00
export function presentFetchCall ( args : { url : string } ) : GenericCallView {
2026-07-03 22:52:18 +08:00
return { card : 'generic' , title : args.url , kind : 'fetch' , rawInput : args.url }
2026-06-25 15:04:12 +08:00
}
2026-07-06 22:09:30 +08:00
/**
* 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.
2026-07-08 14:40:14 +08:00
* @param timeoutMs - the cooperative tool-call budget (ms) attached as the tool's
* `ToolDefinition.timeoutMs` for `@deepseek-ai/dsh-timeout-policy` to enforce.
2026-07-27 12:41:36 +08:00
* @param maxOutputChars - cap on the complete rendered tool output (see
* {@link formatFetchOutput}); markdown escaping can outgrow the provider's
* body cap, so the model-context bound is enforced on the rendered result.
2026-07-06 22:09:30 +08:00
*/
2026-07-27 12:41:36 +08:00
export function applyWebFetchTool ( ctx : Context , timeoutMs : number , maxOutputChars : number ) : void {
2026-06-25 15:04:12 +08:00
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.' } ,
} ,
2026-07-21 03:08:35 +08:00
output : {
schema : {
type : 'object' ,
additionalProperties : false ,
properties : {
url : { type : 'string' , required : true } ,
statusCode : { type : 'integer' , required : true } ,
body : {
required : true ,
oneOf : [
{
type : 'object' ,
additionalProperties : false ,
properties : {
kind : { type : 'string' , required : true , const : 'html' } ,
content : { type : 'string' , required : true } ,
} ,
} ,
{
type : 'object' ,
additionalProperties : false ,
properties : {
kind : { type : 'string' , required : true , const : 'text' } ,
content : { type : 'string' , required : true } ,
} ,
} ,
] ,
} ,
truncated : { type : 'boolean' , required : true } ,
} ,
} ,
2026-07-27 12:41:36 +08:00
render : ( _args , value ) = > [ { type : 'text' , text : formatFetchOutput ( value , maxOutputChars ) } ] ,
2026-07-21 03:08:35 +08:00
} ,
2026-07-08 14:40:14 +08:00
timeoutMs ,
2026-07-18 14:59:26 +08:00
// Provider reads do not mutate parent-agent state.
2026-07-13 11:02:21 +08:00
isConcurrencySafe : ( ) = > true ,
2026-07-21 03:08:35 +08:00
async execute ( args , exec ) {
2026-06-25 15:04:12 +08:00
const input = parseFetchArgs ( args )
const result = await ctx . web . fetch (
2026-07-08 10:06:07 +08:00
{ url : input.url } ,
2026-07-14 04:17:38 +08:00
exec . signal ,
2026-06-25 15:04:12 +08:00
)
2026-07-21 03:08:35 +08:00
return {
url : result.url ,
statusCode : result.statusCode ,
body : { kind : result.body.kind , content : result.body.content } ,
truncated : result.truncated ,
}
2026-06-25 15:04:12 +08:00
} ,
presentCall : presentFetchCall ,
} ) )
}