fetch.ts 3.5 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
  1. /**
  2. * The model-facing `web_fetch` tool: retrieve the content of a specific URL.
  3. * Execution goes through `ctx.web` — this module owns the model-facing schema,
  4. * argument validation, and PRESENTATION (HTML→markdown, truncation formatting),
  5. * while the fetch provider owns safe retrieval (transport, redirects, caps).
  6. */
  7. import type { Context } from 'cordis'
  8. import { defineTool } from '@deepseek-ai/dsh-tools'
  9. import type { GenericCallView } from '@deepseek-ai/dsh-tools'
  10. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  11. import type { WebFetchBody, WebFetchResult } from '@deepseek-ai/dsh-web'
  12. import { assertNever } from '@deepseek-ai/dsh-llm'
  13. import type {} from '@deepseek-ai/dsh-system-prompt'
  14. import { htmlToMarkdown } from './html.ts'
  15. /** Validate value constraints the schema DSL can't express. */
  16. export function parseFetchArgs(args: { url: string; timeout_ms?: number }): { url: string; timeoutMs?: number } {
  17. if (args.url.trim().length === 0) throw new Error('url must be a non-empty string')
  18. if (args.timeout_ms !== undefined && (!Number.isFinite(args.timeout_ms) || args.timeout_ms <= 0)) {
  19. throw new Error('timeout_ms must be a positive number')
  20. }
  21. return { url: args.url, ...args.timeout_ms !== undefined ? { timeoutMs: args.timeout_ms } : {} }
  22. }
  23. /** Render a fetched body to model-facing markdown text. */
  24. export function renderBody(body: WebFetchBody): string {
  25. switch (body.kind) {
  26. case 'html':
  27. return htmlToMarkdown(body.content)
  28. case 'text':
  29. return body.content
  30. /* v8 ignore next 2 -- WebFetchBody is a closed union; this arm is unreachable and only makes adding a kind a compile error. */
  31. default:
  32. return assertNever(body, 'unhandled web fetch body kind')
  33. }
  34. }
  35. /** Format a fetch result as one model-facing text block. */
  36. export function formatFetchOutput(result: WebFetchResult): string {
  37. const header = `Fetched ${result.url} (HTTP ${result.statusCode})`
  38. const footer = result.truncated ? '\n\n(Content truncated. Fetch a more specific URL or section for the full text.)' : ''
  39. return `${header}\n\n${renderBody(result.body)}${footer}`
  40. }
  41. /** Pending-call presentation: a fetch card titled by the URL. */
  42. export function presentFetchCall(args: { url: string; timeout_ms?: number }): GenericCallView {
  43. return { card: 'generic', title: args.url, kind: 'fetch', rawInput: args.url }
  44. }
  45. /** Register the `web_fetch` tool and its system-prompt guidance. */
  46. export function applyWebFetchTool(ctx: Context): void {
  47. ctx.systemPrompt.section({
  48. name: 'tool:web_fetch',
  49. order: 111,
  50. 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.',
  51. })
  52. ctx.tools.register(defineTool({
  53. name: 'web_fetch',
  54. description: 'Fetch the content of a specific HTTP(S) URL and return it decoded to text.',
  55. parameters: {
  56. url: { type: 'string', required: true, description: 'The HTTP(S) URL to fetch.' },
  57. timeout_ms: { type: 'number', description: 'Optional fetch timeout in milliseconds (capped by the provider).' },
  58. },
  59. async execute(args, exec): Promise<ContentBlock[]> {
  60. const input = parseFetchArgs(args)
  61. const result = await ctx.web.fetch(
  62. { url: input.url, ...input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {} },
  63. exec.signal ? { signal: exec.signal } : undefined,
  64. )
  65. return [{ type: 'text', text: formatFetchOutput(result) }]
  66. },
  67. presentCall: presentFetchCall,
  68. }))
  69. }