fetch.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495
  1. /**
  2. * The model-facing `web_fetch` tool. This module owns its schema, validation, and presentation;
  3. * `ctx.web` owns retrieval. Timeout is deployment policy, not a model argument: config becomes
  4. * `ToolDefinition.timeoutMs`, timeout policy enforces it, and this tool forwards the resulting
  5. * signal. A provider timeout remains a backstop for direct seam callers.
  6. */
  7. import type { Context } from 'cordis'
  8. import TurndownService from 'turndown'
  9. import { gfm } from '@joplin/turndown-plugin-gfm'
  10. import { defineTool } from '@deepseek-ai/dsh-tools'
  11. import type { GenericCallView, JsonValue, ToolResult, WebFetchResultView } from '@deepseek-ai/dsh-tools'
  12. import type { WebFetchBody, WebFetchResult } from '@deepseek-ai/dsh-web'
  13. import { assertNever } from '@deepseek-ai/dsh-llm'
  14. import type {} from '@deepseek-ai/dsh-system-prompt'
  15. /**
  16. * The shared HTML→markdown converter: turndown over its bundled domino DOM,
  17. * with GitHub-flavored tables/strikethrough (`@joplin/turndown-plugin-gfm`).
  18. * The style options are fixed model-facing presentation (matching the repo's
  19. * markdown conventions), not deployment tunables. `remove` drops non-content
  20. * elements wholesale — turndown's default keeps their text. The instance is
  21. * stateless across `turndown()` calls and safe to share.
  22. */
  23. const turndown = new TurndownService({
  24. headingStyle: 'atx',
  25. codeBlockStyle: 'fenced',
  26. bulletListMarker: '-',
  27. })
  28. turndown.use(gfm)
  29. turndown.remove(['script', 'style', 'noscript'])
  30. /** Render one GFM table cell without interpreting HTML span counts. */
  31. function renderTableCell(content: string, index: number): string {
  32. const prefix = index === 0 ? '| ' : ' '
  33. const escaped = content.trim().replace(/\n\r/g, '<br>').replace(/\n/g, '<br>').replace(/\|+/g, '\\|').padEnd(3, ' ')
  34. return `${prefix}${escaped} |`
  35. }
  36. /** Whether a row is the table's Markdown heading row. */
  37. function isTableHeadingRow(row: HTMLTableRowElement): boolean {
  38. const cells = Array.from(row.cells)
  39. const section = row.parentElement as HTMLTableSectionElement
  40. const table = section.parentElement as HTMLTableElement
  41. return (section.nodeName === 'THEAD' || table.rows[0] === row)
  42. && cells.every(cell => cell.nodeName === 'TH')
  43. }
  44. /** Map an HTML table-cell alignment to the GFM separator marker. */
  45. function tableBorder(cell: HTMLTableCellElement): string {
  46. const alignment = (cell.getAttribute('align') || cell.style.textAlign || '').toLowerCase()
  47. if (alignment === 'left') return ':---'
  48. if (alignment === 'right') return '---:'
  49. if (alignment === 'center') return ':---:'
  50. return '---'
  51. }
  52. turndown.addRule('tableCellWithoutSpanExpansion', {
  53. filter: ['th', 'td'],
  54. replacement(content, node) {
  55. const cell = node as HTMLTableCellElement
  56. const row = cell.parentNode as HTMLTableRowElement
  57. // GFM cannot represent spanning cells. Ignoring colspan keeps conversion
  58. // work and output proportional to the source instead of the numeric attribute.
  59. return renderTableCell(content, Array.prototype.indexOf.call(row.childNodes, cell))
  60. },
  61. })
  62. turndown.addRule('tableRowWithoutSpanExpansion', {
  63. filter: 'tr',
  64. replacement(content, node) {
  65. const row = node as HTMLTableRowElement
  66. const border = isTableHeadingRow(row)
  67. ? Array.from(row.cells, (cell, index) => renderTableCell(tableBorder(cell), index)).join('')
  68. : ''
  69. return `\n${content}${border.length > 0 ? `\n${border}` : ''}`
  70. },
  71. })
  72. /**
  73. * Validate value constraints the schema DSL can't express: a non-blank `url`.
  74. * Throws a plain `Error` otherwise. No timeout parameter — the tool-call budget
  75. * is deployment policy declared via `fetchTimeoutMs` config and enforced by
  76. * `@deepseek-ai/dsh-timeout-policy`, not a model argument.
  77. *
  78. * @param args - the schema-validated `web_fetch` arguments.
  79. * @returns the arguments as the seam's request fields.
  80. */
  81. export function parseFetchArgs(args: { url: string }): { url: string } {
  82. if (args.url.trim().length === 0) throw new Error('url must be a non-empty string')
  83. return { url: args.url }
  84. }
  85. /**
  86. * Nesting-depth ceiling above which HTML skips conversion and passes through
  87. * raw. Conversion runs synchronously on the event loop, and unclosed-tag
  88. * nesting makes domino's tree (and turndown's walk over it) superlinear —
  89. * measured: depth 512 ≈ 0.15s, 2,000 ≈ 2s, 20,000 ≈ 5s — during which the
  90. * cooperative `fetchTimeoutMs` timer cannot fire. Real pages nest a few dozen
  91. * levels; 512 is far above content and far below weaponizable. A robustness
  92. * invariant, not a tunable.
  93. */
  94. const MAX_CONVERSION_DEPTH = 512
  95. /** Elements that never take a closing tag, so they do not grow the lexical stack. */
  96. const VOID_ELEMENTS = new Set([
  97. 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
  98. 'link', 'meta', 'param', 'source', 'track', 'wbr',
  99. ])
  100. /** Elements whose contents HTML parses as text until their matching end tag. */
  101. const RAW_TEXT_ELEMENTS = new Set(['script', 'style', 'noscript'])
  102. /** Whether a character can occur after a raw-text end-tag name. */
  103. function isTagBoundary(char: string | undefined): boolean {
  104. return char === undefined || char === '>' || char === '/' || /\s/.test(char)
  105. }
  106. /** Find the matching raw-text end tag without interpreting markup-like body text. */
  107. function findRawTextEnd(lowerHtml: string, name: string, from: number): number {
  108. const prefix = `</${name}`
  109. let candidate = lowerHtml.indexOf(prefix, from)
  110. while (candidate !== -1 && !isTagBoundary(lowerHtml[candidate + prefix.length])) {
  111. candidate = lowerHtml.indexOf(prefix, candidate + prefix.length)
  112. }
  113. return candidate
  114. }
  115. /**
  116. * Conservatively reject HTML whose lexical element stack crosses the conversion
  117. * depth ceiling. The single pass ignores closing tags inside comments, skips
  118. * raw-text bodies, respects quoted `>` characters, and only accepts a closing
  119. * tag for the current element; malformed input therefore over-counts rather
  120. * than hiding nesting.
  121. *
  122. * @param html - the decoded HTML body.
  123. * @returns whether the body crosses {@link MAX_CONVERSION_DEPTH}.
  124. */
  125. function exceedsConversionDepth(html: string): boolean {
  126. const lowerHtml = html.toLowerCase()
  127. const openElements: string[] = []
  128. let offset = 0
  129. let inComment = false
  130. while (offset < html.length) {
  131. const start = html.indexOf('<', offset)
  132. if (inComment) {
  133. const end = html.indexOf('-->', offset)
  134. if (end !== -1 && (start === -1 || end < start)) {
  135. inComment = false
  136. offset = end + 3
  137. continue
  138. }
  139. }
  140. if (start === -1) break
  141. if (!inComment && html.startsWith('<!--', start)) {
  142. inComment = true
  143. offset = start + 4
  144. continue
  145. }
  146. let cursor = start + 1
  147. const closing = html[cursor] === '/'
  148. if (closing) cursor += 1
  149. const nameStart = cursor
  150. while (/[a-zA-Z0-9-]/.test(html[cursor] ?? '')) cursor += 1
  151. if (cursor === nameStart || !/[a-zA-Z]/.test(html.charAt(nameStart))) {
  152. offset = start + 1
  153. continue
  154. }
  155. const name = lowerHtml.slice(nameStart, cursor)
  156. let quote: '"' | "'" | undefined
  157. while (cursor < html.length) {
  158. const char = html[cursor]
  159. cursor += 1
  160. if (quote !== undefined) {
  161. if (char === quote) quote = undefined
  162. } else if (char === '"' || char === "'") {
  163. quote = char
  164. } else if (char === '>') {
  165. break
  166. }
  167. }
  168. if (html[cursor - 1] !== '>') break
  169. if (closing) {
  170. if (!inComment && openElements.at(-1) === name) openElements.pop()
  171. } else {
  172. let last = cursor - 2
  173. while (/\s/.test(html.charAt(last))) last -= 1
  174. if (!VOID_ELEMENTS.has(name) && html[last] !== '/') {
  175. openElements.push(name)
  176. if (openElements.length > MAX_CONVERSION_DEPTH) return true
  177. if (!inComment && RAW_TEXT_ELEMENTS.has(name)) {
  178. const end = findRawTextEnd(lowerHtml, name, cursor)
  179. if (end === -1) break
  180. offset = end
  181. continue
  182. }
  183. }
  184. }
  185. offset = cursor
  186. }
  187. return false
  188. }
  189. interface RenderedBody {
  190. /** Converted text, or raw HTML when conversion is unsafe or fails. */
  191. text: string
  192. /** Whether the source was cut before conversion to bound synchronous work. */
  193. sourceTruncated: boolean
  194. }
  195. /**
  196. * Render a fetched body to model-facing markdown text.
  197. *
  198. * @param body - the decoded body; `html` is converted via turndown, `text`
  199. * passes through verbatim.
  200. * @param maxInputChars - maximum source characters processed synchronously.
  201. * @returns the rendered prefix and whether the source was cut. HTML nested
  202. * beyond {@link MAX_CONVERSION_DEPTH} or rejected by turndown passes through
  203. * raw; a degraded page beats an error for a body the provider decoded.
  204. */
  205. function renderBody(body: WebFetchBody, maxInputChars: number): RenderedBody {
  206. const content = body.content.slice(0, maxInputChars)
  207. const sourceTruncated = content.length !== body.content.length
  208. switch (body.kind) {
  209. case 'html':
  210. if (exceedsConversionDepth(content)) return { text: content, sourceTruncated }
  211. try {
  212. return { text: turndown.turndown(content), sourceTruncated }
  213. } catch {
  214. // turndown's DOM walk recurses per element; malformed markup the lexical
  215. // guard cannot model can still throw RangeError. Provider errors stay
  216. // structured WebErrors upstream; conversion failure downgrades to raw HTML.
  217. return { text: content, sourceTruncated }
  218. }
  219. case 'text':
  220. return { text: content, sourceTruncated }
  221. /* v8 ignore next 2 -- WebFetchBody is a closed union; this arm is unreachable and only makes adding a kind a compile error. */
  222. default:
  223. return assertNever(body, 'unhandled web fetch body kind')
  224. }
  225. }
  226. /** The truncation notice appended when the provider or the output cap cut content. */
  227. const TRUNCATION_FOOTER = '\n\n(Content truncated. Fetch a more specific URL or section for the full text.)'
  228. /** A rendered fetch output: the model-facing text and its effective truncation. */
  229. interface RenderedFetch {
  230. /** The complete bounded output — header, rendered body, and truncation footer. */
  231. text: string
  232. /**
  233. * True when the provider capped the body, a pre-conversion source cut applied,
  234. * or the complete output exceeded `maxOutputChars`. This is the effective
  235. * truncation the returned text reflects (its footer), wider than the
  236. * provider-only `WebFetchResult.truncated`.
  237. */
  238. truncated: boolean
  239. }
  240. /**
  241. * Render a fetch result to its bounded model-facing text and effective
  242. * truncation. The single source of both the `render` text and the fetch card's
  243. * `truncated`, so the card never disagrees with the text the model saw. The cap
  244. * limits the source prefix processed synchronously, then applies again where the
  245. * complete output — header, rendered body, and footer — is known.
  246. *
  247. * Package-internal: the only callers are {@link formatFetchOutput} and
  248. * {@link fetchMetaFromValue}, both reached through the tool registry, which
  249. * deep-freezes the result value before calling `output.render` and
  250. * `output.presentationMeta`. The conversion is memoized per
  251. * `(result, maxOutputChars)` so the synchronous DOM parse and turndown walk run
  252. * once, not twice, on that same frozen value. Keeping it unexported means no
  253. * caller can mutate a cached input or the returned {@link RenderedFetch}, so the
  254. * memo needs no defensive copy.
  255. *
  256. * @param result - the seam's fetch outcome.
  257. * @param maxOutputChars - cap on the complete returned string; a cut body gets
  258. * the same fetch-something-narrower notice as provider-side truncation.
  259. * @returns the complete `Fetched <url> (HTTP <status>)`-headed text and whether
  260. * the provider, a source cut, or the cap trimmed the content.
  261. */
  262. function renderFetchOutput(result: WebFetchResult, maxOutputChars: number): RenderedFetch {
  263. const byCap = renderCache.get(result) ?? new Map<number, RenderedFetch>()
  264. const cached = byCap.get(maxOutputChars)
  265. if (cached !== undefined) return cached
  266. const computed = computeFetchOutput(result, maxOutputChars)
  267. byCap.set(maxOutputChars, computed)
  268. renderCache.set(result, byCap)
  269. return computed
  270. }
  271. /**
  272. * Per-result memo for {@link renderFetchOutput}, keyed first on the frozen
  273. * result value so a garbage-collected result drops its entry, then on the output
  274. * cap (a deployment constant per registration). Collapses the registry's twin
  275. * `render`/`presentationMeta` calls into one HTML→markdown conversion.
  276. */
  277. const renderCache = new WeakMap<WebFetchResult, Map<number, RenderedFetch>>()
  278. /**
  279. * The uncached conversion behind {@link renderFetchOutput}. Separated so the
  280. * memo wraps exactly one call site and the conversion logic stays pure.
  281. *
  282. * @param result - the seam's fetch outcome.
  283. * @param maxOutputChars - cap on the complete returned string.
  284. * @returns the bounded text and effective truncation.
  285. */
  286. function computeFetchOutput(result: WebFetchResult, maxOutputChars: number): RenderedFetch {
  287. const header = `Fetched ${result.url} (HTTP ${result.statusCode})\n\n`
  288. const rendered = renderBody(result.body, maxOutputChars)
  289. const prefix = `${header}${rendered.text}`
  290. const truncated = result.truncated || rendered.sourceTruncated || prefix.length > maxOutputChars
  291. const full = `${prefix}${truncated ? TRUNCATION_FOOTER : ''}`
  292. if (full.length <= maxOutputChars) return { text: full, truncated }
  293. if (maxOutputChars < TRUNCATION_FOOTER.length) return { text: full.slice(0, maxOutputChars), truncated }
  294. return { text: `${prefix.slice(0, maxOutputChars - TRUNCATION_FOOTER.length)}${TRUNCATION_FOOTER}`, truncated }
  295. }
  296. /**
  297. * Format a fetch result as one model-facing text block, bounded as a whole.
  298. *
  299. * @param result - the seam's fetch outcome.
  300. * @param maxOutputChars - cap on the complete returned string.
  301. * @returns the complete text from {@link renderFetchOutput}.
  302. */
  303. export function formatFetchOutput(result: WebFetchResult, maxOutputChars: number): string {
  304. return renderFetchOutput(result, maxOutputChars).text
  305. }
  306. /**
  307. * Pending-call presentation: a fetch card titled by the URL.
  308. *
  309. * @param args - the raw tool arguments; only `url` feeds the view.
  310. * @returns the generic card view (`kind: 'fetch'`) shown while the call runs.
  311. */
  312. export function presentFetchCall(args: { url: string }): GenericCallView {
  313. return { card: 'generic', title: args.url, kind: 'fetch', rawInput: args.url }
  314. }
  315. /**
  316. * The `web_fetch` tool's private `tool/result` `meta` payload: the fetch summary
  317. * a UI cannot recover from the model-facing render text without reparsing its
  318. * header line. Attached opaquely (as `JsonValue`) on the tool result and
  319. * persisted with the session log, so `presentResult` reproduces the fetch card
  320. * on replay. The body itself is already markdown in the result content, so it is
  321. * not duplicated here. `truncated` is the effective truncation the render text
  322. * reflects, which a client cannot recompute (it does not know the deployment's
  323. * `fetchMaxOutputChars`); this is why fetch meta is carried, not derived from the
  324. * header line (see the web-result-card Agent Note).
  325. */
  326. export interface WebFetchMeta {
  327. /** The final URL after allowed redirects. */
  328. url: string
  329. /** HTTP status code of the fetched response. */
  330. statusCode: number
  331. /** True when the provider, a source cut, or the output cap trimmed the content. */
  332. truncated: boolean
  333. }
  334. /**
  335. * Project a validated `web_fetch` output value into its replayable presentation
  336. * meta ({@link WebFetchMeta} as opaque JSON). `truncated` is the effective
  337. * truncation the model-facing text reflects (via {@link renderFetchOutput}), not
  338. * the provider-only `WebFetchResult.truncated`, so the fetch card never disagrees
  339. * with the returned text.
  340. *
  341. * @param value - the canonical `web_fetch` output value (the seam's result shape).
  342. * @param maxOutputChars - the deployment's output cap, the same one
  343. * {@link formatFetchOutput} applies to the render text.
  344. * @returns the URL, status code, and effective truncation flag.
  345. */
  346. export function fetchMetaFromValue(value: WebFetchResult, maxOutputChars: number): JsonValue {
  347. return { url: value.url, statusCode: value.statusCode, truncated: renderFetchOutput(value, maxOutputChars).truncated }
  348. }
  349. /**
  350. * Narrow opaque live or replayed result metadata to a {@link WebFetchMeta}.
  351. * Malformed metadata returns `undefined` so presentation can fall back to the
  352. * generic card instead of throwing during replay.
  353. *
  354. * @param meta - result metadata.
  355. * @returns the validated fetch meta, or `undefined` for absent or malformed data.
  356. */
  357. export function fetchMetaFromResult(meta: unknown): WebFetchMeta | undefined {
  358. if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
  359. const { url, statusCode, truncated } = meta as Record<string, unknown>
  360. if (typeof url !== 'string' || typeof statusCode !== 'number' || typeof truncated !== 'boolean') return undefined
  361. return { url, statusCode, truncated }
  362. }
  363. /**
  364. * Completed-call presentation: a `web` fetch card carrying the retrieval summary
  365. * from `meta`. It sets no `content` copy — a UI without the `web` capability
  366. * falls back to the raw `tool/result` content, the already-markdown body (see the
  367. * web-result-card Agent Note).
  368. *
  369. * @param args - the raw tool arguments; `url` becomes the result-state title so a
  370. * window-truncated replay that dropped the call head still has one.
  371. * @param result - the final model-facing tool result; `meta` carries the summary.
  372. * @returns the fetch result view, or `undefined` (generic card) on failure or
  373. * malformed meta.
  374. */
  375. export function presentFetchResult(args: { url: string }, result: ToolResult): WebFetchResultView | undefined {
  376. if (result.isError) return undefined
  377. const meta = fetchMetaFromResult(result.meta)
  378. if (meta === undefined) return undefined
  379. return {
  380. card: 'web',
  381. kind: 'fetch',
  382. title: args.url,
  383. url: meta.url,
  384. statusCode: meta.statusCode,
  385. truncated: meta.truncated,
  386. }
  387. }
  388. /**
  389. * Register the `web_fetch` tool and its system-prompt guidance.
  390. *
  391. * @param ctx - context whose `tools` and `systemPrompt` registries receive the
  392. * registrations; both are effect-scoped and unregister on plugin dispose.
  393. * @param timeoutMs - the cooperative tool-call budget (ms) attached as the tool's
  394. * `ToolDefinition.timeoutMs` for `@deepseek-ai/dsh-timeout-policy` to enforce.
  395. * @param maxOutputChars - cap on the complete rendered tool output (see
  396. * {@link formatFetchOutput}) and on source characters converted synchronously.
  397. */
  398. export function applyWebFetchTool(ctx: Context, timeoutMs: number, maxOutputChars: number): void {
  399. ctx.systemPrompt.section({
  400. name: 'tool:web_fetch',
  401. order: 111,
  402. 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.',
  403. })
  404. ctx.tools.register(defineTool({
  405. name: 'web_fetch',
  406. description: 'Fetch the content of a specific HTTP(S) URL and return it decoded to text.',
  407. parameters: {
  408. url: { type: 'string', required: true, description: 'The HTTP(S) URL to fetch.' },
  409. },
  410. output: {
  411. schema: {
  412. type: 'object',
  413. additionalProperties: false,
  414. properties: {
  415. url: { type: 'string', required: true },
  416. statusCode: { type: 'integer', required: true },
  417. body: {
  418. required: true,
  419. oneOf: [
  420. {
  421. type: 'object',
  422. additionalProperties: false,
  423. properties: {
  424. kind: { type: 'string', required: true, const: 'html' },
  425. content: { type: 'string', required: true },
  426. },
  427. },
  428. {
  429. type: 'object',
  430. additionalProperties: false,
  431. properties: {
  432. kind: { type: 'string', required: true, const: 'text' },
  433. content: { type: 'string', required: true },
  434. },
  435. },
  436. ],
  437. },
  438. truncated: { type: 'boolean', required: true },
  439. },
  440. },
  441. render: (_args, value) => [{ type: 'text', text: formatFetchOutput(value, maxOutputChars) }],
  442. presentationMeta: (_args, value) => fetchMetaFromValue(value, maxOutputChars),
  443. },
  444. timeoutMs,
  445. // Provider reads do not mutate parent-agent state.
  446. isConcurrencySafe: () => true,
  447. async execute(args, exec) {
  448. const input = parseFetchArgs(args)
  449. const result = await ctx.web.fetch(
  450. { url: input.url },
  451. exec.signal,
  452. )
  453. return {
  454. url: result.url,
  455. statusCode: result.statusCode,
  456. body: { kind: result.body.kind, content: result.body.content },
  457. truncated: result.truncated,
  458. }
  459. },
  460. presentCall: presentFetchCall,
  461. presentResult: (args, result) => presentFetchResult(args, result),
  462. }))
  463. }