/** * 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. */ import type { Context } from 'cordis' import TurndownService from 'turndown' import { gfm } from '@joplin/turndown-plugin-gfm' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView } from '@deepseek-ai/dsh-tools' import type { WebFetchBody, WebFetchResult } from '@deepseek-ai/dsh-web' import { assertNever } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-system-prompt' /** * 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']) /** Render one GFM table cell without interpreting HTML span counts. */ function renderTableCell(content: string, index: number): string { const prefix = index === 0 ? '| ' : ' ' const escaped = content.trim().replace(/\n\r/g, '
').replace(/\n/g, '
').replace(/\|+/g, '\\|').padEnd(3, ' ') return `${prefix}${escaped} |` } /** Whether a row is the table's Markdown heading row. */ function isTableHeadingRow(row: HTMLTableRowElement): boolean { const cells = Array.from(row.cells) const section = row.parentElement as HTMLTableSectionElement const table = section.parentElement as HTMLTableElement return (section.nodeName === 'THEAD' || table.rows[0] === row) && cells.every(cell => cell.nodeName === 'TH') } /** Map an HTML table-cell alignment to the GFM separator marker. */ function tableBorder(cell: HTMLTableCellElement): string { const alignment = (cell.getAttribute('align') || cell.style.textAlign || '').toLowerCase() if (alignment === 'left') return ':---' if (alignment === 'right') return '---:' if (alignment === 'center') return ':---:' return '---' } turndown.addRule('tableCellWithoutSpanExpansion', { filter: ['th', 'td'], replacement(content, node) { const cell = node as HTMLTableCellElement const row = cell.parentNode as HTMLTableRowElement // GFM cannot represent spanning cells. Ignoring colspan keeps conversion // work and output proportional to the source instead of the numeric attribute. return renderTableCell(content, Array.prototype.indexOf.call(row.childNodes, cell)) }, }) turndown.addRule('tableRowWithoutSpanExpansion', { filter: 'tr', replacement(content, node) { const row = node as HTMLTableRowElement const border = isTableHeadingRow(row) ? Array.from(row.cells, (cell, index) => renderTableCell(tableBorder(cell), index)).join('') : '' return `\n${content}${border.length > 0 ? `\n${border}` : ''}` }, }) /** * 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') return { url: args.url } } /** * 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 do not grow the lexical stack. */ const VOID_ELEMENTS = new Set([ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr', ]) /** Elements whose contents HTML parses as text until their matching end tag. */ const RAW_TEXT_ELEMENTS = new Set(['script', 'style', 'noscript']) /** Whether a character can occur after a raw-text end-tag name. */ function isTagBoundary(char: string | undefined): boolean { return char === undefined || char === '>' || char === '/' || /\s/.test(char) } /** Find the matching raw-text end tag without interpreting markup-like body text. */ function findRawTextEnd(lowerHtml: string, name: string, from: number): number { const prefix = `` characters, and only accepts a closing * tag for the current element; malformed input therefore over-counts rather * than hiding nesting. * * @param html - the decoded HTML body. * @returns whether the body crosses {@link MAX_CONVERSION_DEPTH}. */ function exceedsConversionDepth(html: string): boolean { const lowerHtml = html.toLowerCase() const openElements: string[] = [] let offset = 0 let inComment = false while (offset < html.length) { const start = html.indexOf('<', offset) if (inComment) { const end = html.indexOf('-->', offset) if (end !== -1 && (start === -1 || end < start)) { inComment = false offset = end + 3 continue } } if (start === -1) break if (!inComment && html.startsWith('