@deepseek-ai/dsh-tool-web 0.0.1-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,777 @@
1
+ import z from "@deepseek-ai/schemastery";
2
+ import { defineTool } from "@deepseek-ai/dsh-tools";
3
+ import TurndownService from "turndown";
4
+ import { gfm } from "@joplin/turndown-plugin-gfm";
5
+ import { assertNever } from "@deepseek-ai/dsh-llm";
6
+ //#region lib/types/search.js
7
+ /**
8
+ * The model-facing `web_search` tool: discover current information on the web.
9
+ * Execution goes through `ctx.web` — this module owns only the model-facing
10
+ * schema, argument validation, the result-count bound, and result formatting,
11
+ * never provider selection or network access.
12
+ */
13
+ /**
14
+ * Default upper bound on returned sources (the `searchMaxResults` config).
15
+ * Owned by the consumer (not the provider or model), mirroring `dsh-tool-fs`'s
16
+ * `READ_LIMIT`. The model just asks a question; the product controls how much
17
+ * context returns. The default `8` aligns with OpenCode's Exa default.
18
+ */
19
+ const WEB_SEARCH_MAX_RESULTS = 8;
20
+ /**
21
+ * Validate value constraints the schema DSL can't express: a non-blank
22
+ * `query`. Throws a plain `Error` otherwise.
23
+ *
24
+ * @param args - the schema-validated `web_search` arguments.
25
+ * @returns the accepted arguments, passed through unchanged.
26
+ */
27
+ function parseSearchArgs(args) {
28
+ if (args.query.trim().length === 0) throw new Error("query must be a non-empty string");
29
+ return { query: args.query };
30
+ }
31
+ /** Display label for a source: its title, else its hostname. */
32
+ function sourceLabel(url, title) {
33
+ if (title !== void 0 && title.length > 0) return title;
34
+ try {
35
+ return new URL(url).hostname;
36
+ } catch {
37
+ return url;
38
+ }
39
+ }
40
+ /**
41
+ * Format a search result as one model-facing text block.
42
+ *
43
+ * @param result - the seam's search outcome.
44
+ * @returns the provider answer (when any), a markdown source list with snippet
45
+ * and date metadata (or `No results found.`), a refine-the-query note when
46
+ * truncated, and a standing cite-your-sources instruction.
47
+ */
48
+ function formatSearchOutput(result) {
49
+ const parts = [];
50
+ if (result.content !== void 0 && result.content.length > 0) parts.push(result.content);
51
+ if (result.sources.length > 0) {
52
+ const lines = result.sources.map((source) => {
53
+ const label = sourceLabel(source.url, source.title);
54
+ const meta = [];
55
+ if (source.snippet !== void 0 && source.snippet.length > 0) meta.push(source.snippet);
56
+ if (source.publishedAt !== void 0 && source.publishedAt.length > 0) meta.push(`(${source.publishedAt})`);
57
+ const suffix = meta.length > 0 ? ` — ${meta.join(" ")}` : "";
58
+ return `- [${label}](${source.url})${suffix}`;
59
+ });
60
+ parts.push(`Sources:\n${lines.join("\n")}`);
61
+ } else if (result.content === void 0 || result.content.length === 0) parts.push("No results found.");
62
+ if (result.truncated) parts.push(`(Showing the first ${result.sources.length} sources. Refine the query for more.)`);
63
+ parts.push("Cite the relevant URLs above as markdown links in your answer.");
64
+ return parts.join("\n\n");
65
+ }
66
+ /**
67
+ * Pending-call presentation: a search card titled by the query.
68
+ *
69
+ * @param args - the raw tool arguments; only `query` feeds the view.
70
+ * @returns the generic card view (`kind: 'search'`) shown while the call runs.
71
+ */
72
+ function presentSearchCall(args) {
73
+ return {
74
+ card: "generic",
75
+ title: args.query,
76
+ kind: "search",
77
+ rawInput: args.query
78
+ };
79
+ }
80
+ /**
81
+ * Project one seam source into a plain object that omits every absent optional
82
+ * field. Shared by the canonical `execute` result and its replayable
83
+ * presentation meta so both carry byte-identical source shapes.
84
+ *
85
+ * @param source - one source from the `ctx.web` search outcome.
86
+ * @returns `{ url }` plus each present optional field.
87
+ */
88
+ function projectSource(source) {
89
+ return {
90
+ url: source.url,
91
+ ...source.title !== void 0 ? { title: source.title } : {},
92
+ ...source.snippet !== void 0 ? { snippet: source.snippet } : {},
93
+ ...source.publishedAt !== void 0 ? { publishedAt: source.publishedAt } : {}
94
+ };
95
+ }
96
+ /**
97
+ * Project a validated `web_search` output value into its replayable
98
+ * presentation meta ({@link WebSearchMeta} as opaque JSON).
99
+ *
100
+ * @param value - the canonical `web_search` output value (the seam's result shape).
101
+ * @returns the structured sources, the truncation flag, and the answer when present.
102
+ */
103
+ function searchMetaFromValue(value) {
104
+ return {
105
+ sources: value.sources.map(projectSource),
106
+ truncated: value.truncated,
107
+ ...value.content !== void 0 ? { answer: value.content } : {}
108
+ };
109
+ }
110
+ /** Whether `value` is a valid {@link WebSource} (defensive narrowing from opaque `meta`). */
111
+ function isWebSource(value) {
112
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
113
+ const { url, title, snippet, publishedAt } = value;
114
+ return typeof url === "string" && (title === void 0 || typeof title === "string") && (snippet === void 0 || typeof snippet === "string") && (publishedAt === void 0 || typeof publishedAt === "string");
115
+ }
116
+ /**
117
+ * Narrow opaque live or replayed result metadata to a {@link WebSearchMeta}.
118
+ * Malformed metadata returns `undefined` so presentation can fall back to the
119
+ * generic card instead of throwing during replay.
120
+ *
121
+ * @param meta - result metadata.
122
+ * @returns the validated search meta, or `undefined` for absent or malformed data.
123
+ */
124
+ function searchMetaFromResult(meta) {
125
+ if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return void 0;
126
+ const { sources, truncated, answer } = meta;
127
+ if (!Array.isArray(sources) || !sources.every(isWebSource)) return void 0;
128
+ if (typeof truncated !== "boolean") return void 0;
129
+ if (answer !== void 0 && typeof answer !== "string") return void 0;
130
+ return {
131
+ sources,
132
+ truncated,
133
+ ...answer !== void 0 ? { answer } : {}
134
+ };
135
+ }
136
+ /**
137
+ * Completed-call presentation: a `web` search card carrying the faithful
138
+ * structured sources from `meta`. It sets no `content` copy — a UI without the
139
+ * `web` capability falls back to the raw `tool/result` content, which is the
140
+ * same text (see the web-result-card Agent Note).
141
+ *
142
+ * @param args - the raw tool arguments; `query` becomes the result-state title so
143
+ * a window-truncated replay that dropped the call head still has one.
144
+ * @param result - the final model-facing tool result; `meta` carries the sources.
145
+ * @returns the search result view, or `undefined` (generic card) on failure or
146
+ * malformed meta.
147
+ */
148
+ function presentSearchResult(args, result) {
149
+ if (result.isError) return void 0;
150
+ const meta = searchMetaFromResult(result.meta);
151
+ if (meta === void 0) return void 0;
152
+ return {
153
+ card: "web",
154
+ kind: "search",
155
+ title: args.query,
156
+ sources: meta.sources,
157
+ truncated: meta.truncated,
158
+ ...meta.answer !== void 0 ? { answer: meta.answer } : {}
159
+ };
160
+ }
161
+ /**
162
+ * Register the `web_search` tool and its system-prompt guidance.
163
+ *
164
+ * @param ctx - context whose `tools` and `systemPrompt` registries receive the
165
+ * registrations; both are effect-scoped and unregister on plugin dispose.
166
+ * @param maxResults - the deployment's source cap, sent as every seam
167
+ * request's `maxResults`.
168
+ * @param timeoutMs - the cooperative tool-call budget (ms) attached as the tool's
169
+ * `ToolDefinition.timeoutMs` for `@deepseek-ai/dsh-timeout-policy` to enforce.
170
+ * @param fetchEnabled - whether the same composition exposes `web_fetch`, which
171
+ * controls whether search guidance may recommend that follow-up tool.
172
+ */
173
+ function applyWebSearchTool(ctx, maxResults, timeoutMs, fetchEnabled) {
174
+ ctx.systemPrompt.section({
175
+ name: "tool:web_search",
176
+ order: 110,
177
+ text: fetchEnabled ? "Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links." : "Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links."
178
+ });
179
+ ctx.tools.register(defineTool({
180
+ name: "web_search",
181
+ description: "Search the web for current information. Returns an optional summary answer and a list of source URLs.",
182
+ parameters: { query: {
183
+ type: "string",
184
+ required: true,
185
+ description: "The search query."
186
+ } },
187
+ output: {
188
+ schema: {
189
+ type: "object",
190
+ additionalProperties: false,
191
+ properties: {
192
+ content: { type: "string" },
193
+ sources: {
194
+ type: "array",
195
+ required: true,
196
+ items: {
197
+ type: "object",
198
+ additionalProperties: false,
199
+ properties: {
200
+ url: {
201
+ type: "string",
202
+ required: true
203
+ },
204
+ title: { type: "string" },
205
+ snippet: { type: "string" },
206
+ publishedAt: { type: "string" }
207
+ }
208
+ }
209
+ },
210
+ truncated: {
211
+ type: "boolean",
212
+ required: true
213
+ }
214
+ }
215
+ },
216
+ render: (_args, value) => [{
217
+ type: "text",
218
+ text: formatSearchOutput(value)
219
+ }],
220
+ presentationMeta: (_args, value) => searchMetaFromValue(value)
221
+ },
222
+ timeoutMs,
223
+ isConcurrencySafe: () => true,
224
+ async execute(args, exec) {
225
+ const input = parseSearchArgs(args);
226
+ const result = await ctx.web.search({
227
+ query: input.query,
228
+ maxResults
229
+ }, exec.signal);
230
+ return {
231
+ ...result.content !== void 0 ? { content: result.content } : {},
232
+ sources: result.sources.map(projectSource),
233
+ truncated: result.truncated
234
+ };
235
+ },
236
+ presentCall: presentSearchCall,
237
+ presentResult: (args, result) => presentSearchResult(args, result)
238
+ }));
239
+ }
240
+ //#endregion
241
+ //#region lib/types/fetch.js
242
+ /**
243
+ * The model-facing `web_fetch` tool. This module owns its schema, validation, and presentation;
244
+ * `ctx.web` owns retrieval. Timeout is deployment policy, not a model argument: config becomes
245
+ * `ToolDefinition.timeoutMs`, timeout policy enforces it, and this tool forwards the resulting
246
+ * signal. A provider timeout remains a backstop for direct service callers.
247
+ */
248
+ /**
249
+ * The shared HTML→markdown converter: turndown over its bundled domino DOM,
250
+ * with GitHub-flavored tables/strikethrough (`@joplin/turndown-plugin-gfm`).
251
+ * The style options are fixed model-facing presentation (matching the repo's
252
+ * markdown conventions), not deployment tunables. `remove` drops non-content
253
+ * elements wholesale — turndown's default keeps their text. The instance is
254
+ * stateless across `turndown()` calls and safe to share.
255
+ */
256
+ const turndown = new TurndownService({
257
+ headingStyle: "atx",
258
+ codeBlockStyle: "fenced",
259
+ bulletListMarker: "-"
260
+ });
261
+ turndown.use(gfm);
262
+ turndown.remove([
263
+ "script",
264
+ "style",
265
+ "noscript"
266
+ ]);
267
+ /** Render one GFM table cell without interpreting HTML span counts. */
268
+ function renderTableCell(content, index) {
269
+ return `${index === 0 ? "| " : " "}${content.trim().replace(/\n\r/g, "<br>").replace(/\n/g, "<br>").replace(/\|+/g, "\\|").padEnd(3, " ")} |`;
270
+ }
271
+ /** Whether a row is the table's Markdown heading row. */
272
+ function isTableHeadingRow(row) {
273
+ const cells = Array.from(row.cells);
274
+ const section = row.parentElement;
275
+ const table = section.parentElement;
276
+ return (section.nodeName === "THEAD" || table.rows[0] === row) && cells.every((cell) => cell.nodeName === "TH");
277
+ }
278
+ /** Map an HTML table-cell alignment to the GFM separator marker. */
279
+ function tableBorder(cell) {
280
+ const alignment = (cell.getAttribute("align") || cell.style.textAlign || "").toLowerCase();
281
+ if (alignment === "left") return ":---";
282
+ if (alignment === "right") return "---:";
283
+ if (alignment === "center") return ":---:";
284
+ return "---";
285
+ }
286
+ turndown.addRule("tableCellWithoutSpanExpansion", {
287
+ filter: ["th", "td"],
288
+ replacement(content, node) {
289
+ const cell = node;
290
+ const row = cell.parentNode;
291
+ return renderTableCell(content, Array.prototype.indexOf.call(row.childNodes, cell));
292
+ }
293
+ });
294
+ turndown.addRule("tableRowWithoutSpanExpansion", {
295
+ filter: "tr",
296
+ replacement(content, node) {
297
+ const row = node;
298
+ const border = isTableHeadingRow(row) ? Array.from(row.cells, (cell, index) => renderTableCell(tableBorder(cell), index)).join("") : "";
299
+ return `\n${content}${border.length > 0 ? `\n${border}` : ""}`;
300
+ }
301
+ });
302
+ /**
303
+ * Validate value constraints the schema DSL can't express: a non-blank `url`.
304
+ * Throws a plain `Error` otherwise. No timeout parameter — the tool-call budget
305
+ * is deployment policy declared via `fetchTimeoutMs` config and enforced by
306
+ * `@deepseek-ai/dsh-timeout-policy`, not a model argument.
307
+ *
308
+ * @param args - the schema-validated `web_fetch` arguments.
309
+ * @returns the arguments as the seam's request fields.
310
+ */
311
+ function parseFetchArgs(args) {
312
+ if (args.url.trim().length === 0) throw new Error("url must be a non-empty string");
313
+ return { url: args.url };
314
+ }
315
+ /**
316
+ * Nesting-depth ceiling above which HTML skips conversion and passes through
317
+ * raw. Conversion runs synchronously on the event loop, and unclosed-tag
318
+ * nesting makes domino's tree (and turndown's walk over it) superlinear —
319
+ * measured: depth 512 ≈ 0.15s, 2,000 ≈ 2s, 20,000 ≈ 5s — during which the
320
+ * cooperative `fetchTimeoutMs` timer cannot fire. Real pages nest a few dozen
321
+ * levels; 512 is far above content and far below weaponizable. A robustness
322
+ * invariant, not a tunable.
323
+ */
324
+ const MAX_CONVERSION_DEPTH = 512;
325
+ /** Elements that never take a closing tag, so they do not grow the lexical stack. */
326
+ const VOID_ELEMENTS = new Set([
327
+ "area",
328
+ "base",
329
+ "br",
330
+ "col",
331
+ "embed",
332
+ "hr",
333
+ "img",
334
+ "input",
335
+ "link",
336
+ "meta",
337
+ "param",
338
+ "source",
339
+ "track",
340
+ "wbr"
341
+ ]);
342
+ /** Elements whose contents HTML parses as text until their matching end tag. */
343
+ const RAW_TEXT_ELEMENTS = new Set([
344
+ "script",
345
+ "style",
346
+ "noscript"
347
+ ]);
348
+ /** Whether a character can occur after a raw-text end-tag name. */
349
+ function isTagBoundary(char) {
350
+ return char === void 0 || char === ">" || char === "/" || /\s/.test(char);
351
+ }
352
+ /** Find the matching raw-text end tag without interpreting markup-like body text. */
353
+ function findRawTextEnd(lowerHtml, name, from) {
354
+ const prefix = `</${name}`;
355
+ let candidate = lowerHtml.indexOf(prefix, from);
356
+ while (candidate !== -1 && !isTagBoundary(lowerHtml[candidate + prefix.length])) candidate = lowerHtml.indexOf(prefix, candidate + prefix.length);
357
+ return candidate;
358
+ }
359
+ /**
360
+ * Conservatively reject HTML whose lexical element stack crosses the conversion
361
+ * depth ceiling. The single pass ignores closing tags inside comments, skips
362
+ * raw-text bodies, respects quoted `>` characters, and only accepts a closing
363
+ * tag for the current element; malformed input therefore over-counts rather
364
+ * than hiding nesting.
365
+ *
366
+ * @param html - the decoded HTML body.
367
+ * @returns whether the body crosses {@link MAX_CONVERSION_DEPTH}.
368
+ */
369
+ function exceedsConversionDepth(html) {
370
+ const lowerHtml = html.toLowerCase();
371
+ const openElements = [];
372
+ let offset = 0;
373
+ let inComment = false;
374
+ while (offset < html.length) {
375
+ const start = html.indexOf("<", offset);
376
+ if (inComment) {
377
+ const end = html.indexOf("-->", offset);
378
+ if (end !== -1 && (start === -1 || end < start)) {
379
+ inComment = false;
380
+ offset = end + 3;
381
+ continue;
382
+ }
383
+ }
384
+ if (start === -1) break;
385
+ if (!inComment && html.startsWith("<!--", start)) {
386
+ inComment = true;
387
+ offset = start + 4;
388
+ continue;
389
+ }
390
+ let cursor = start + 1;
391
+ const closing = html[cursor] === "/";
392
+ if (closing) cursor += 1;
393
+ const nameStart = cursor;
394
+ while (/[a-zA-Z0-9-]/.test(html[cursor] ?? "")) cursor += 1;
395
+ if (cursor === nameStart || !/[a-zA-Z]/.test(html.charAt(nameStart))) {
396
+ offset = start + 1;
397
+ continue;
398
+ }
399
+ const name = lowerHtml.slice(nameStart, cursor);
400
+ let quote;
401
+ while (cursor < html.length) {
402
+ const char = html[cursor];
403
+ cursor += 1;
404
+ if (quote !== void 0) {
405
+ if (char === quote) quote = void 0;
406
+ } else if (char === "\"" || char === "'") quote = char;
407
+ else if (char === ">") break;
408
+ }
409
+ if (html[cursor - 1] !== ">") break;
410
+ if (closing) {
411
+ if (!inComment && openElements.at(-1) === name) openElements.pop();
412
+ } else {
413
+ let last = cursor - 2;
414
+ while (/\s/.test(html.charAt(last))) last -= 1;
415
+ if (!VOID_ELEMENTS.has(name) && html[last] !== "/") {
416
+ openElements.push(name);
417
+ if (openElements.length > MAX_CONVERSION_DEPTH) return true;
418
+ if (!inComment && RAW_TEXT_ELEMENTS.has(name)) {
419
+ const end = findRawTextEnd(lowerHtml, name, cursor);
420
+ if (end === -1) break;
421
+ offset = end;
422
+ continue;
423
+ }
424
+ }
425
+ }
426
+ offset = cursor;
427
+ }
428
+ return false;
429
+ }
430
+ /**
431
+ * Render a fetched body to model-facing markdown text.
432
+ *
433
+ * @param body - the decoded body; `html` is converted via turndown, `text`
434
+ * passes through verbatim.
435
+ * @param maxInputChars - maximum source characters processed synchronously.
436
+ * @returns the rendered prefix and whether the source was cut. HTML nested
437
+ * beyond {@link MAX_CONVERSION_DEPTH} or rejected by turndown passes through
438
+ * raw; a degraded page beats an error for a body the provider decoded.
439
+ */
440
+ function renderBody(body, maxInputChars) {
441
+ const content = body.content.slice(0, maxInputChars);
442
+ const sourceTruncated = content.length !== body.content.length;
443
+ switch (body.kind) {
444
+ case "html":
445
+ if (exceedsConversionDepth(content)) return {
446
+ text: content,
447
+ sourceTruncated
448
+ };
449
+ try {
450
+ return {
451
+ text: turndown.turndown(content),
452
+ sourceTruncated
453
+ };
454
+ } catch {
455
+ return {
456
+ text: content,
457
+ sourceTruncated
458
+ };
459
+ }
460
+ case "text": return {
461
+ text: content,
462
+ sourceTruncated
463
+ };
464
+ /* v8 ignore next 2 -- WebFetchBody is a closed union; this arm is unreachable and only makes adding a kind a compile error. */
465
+ default: return assertNever(body, "unhandled web fetch body kind");
466
+ }
467
+ }
468
+ /** The truncation notice appended when the provider or the output cap cut content. */
469
+ const TRUNCATION_FOOTER = "\n\n(Content truncated. Fetch a more specific URL or section for the full text.)";
470
+ /**
471
+ * Render a fetch result to its bounded model-facing text and effective
472
+ * truncation. The single source of both the `render` text and the fetch card's
473
+ * `truncated`, so the card never disagrees with the text the model saw. The cap
474
+ * limits the source prefix processed synchronously, then applies again where the
475
+ * complete output — header, rendered body, and footer — is known.
476
+ *
477
+ * Package-internal: the only callers are {@link formatFetchOutput} and
478
+ * {@link fetchMetaFromValue}, both reached through the tool registry, which
479
+ * deep-freezes the result value before calling `output.render` and
480
+ * `output.presentationMeta`. The conversion is memoized per
481
+ * `(result, maxOutputChars)` so the synchronous DOM parse and turndown walk run
482
+ * once, not twice, on that same frozen value. Keeping it unexported means no
483
+ * caller can mutate a cached input or the returned {@link RenderedFetch}, so the
484
+ * memo needs no defensive copy.
485
+ *
486
+ * @param result - the seam's fetch outcome.
487
+ * @param maxOutputChars - cap on the complete returned string; a cut body gets
488
+ * the same fetch-something-narrower notice as provider-side truncation.
489
+ * @returns the complete `Fetched <url> (HTTP <status>)`-headed text and whether
490
+ * the provider, a source cut, or the cap trimmed the content.
491
+ */
492
+ function renderFetchOutput(result, maxOutputChars) {
493
+ const byCap = renderCache.get(result) ?? /* @__PURE__ */ new Map();
494
+ const cached = byCap.get(maxOutputChars);
495
+ if (cached !== void 0) return cached;
496
+ const computed = computeFetchOutput(result, maxOutputChars);
497
+ byCap.set(maxOutputChars, computed);
498
+ renderCache.set(result, byCap);
499
+ return computed;
500
+ }
501
+ /**
502
+ * Per-result memo for {@link renderFetchOutput}, keyed first on the frozen
503
+ * result value so a garbage-collected result drops its entry, then on the output
504
+ * cap (a deployment constant per registration). Collapses the registry's twin
505
+ * `render`/`presentationMeta` calls into one HTML→markdown conversion.
506
+ */
507
+ const renderCache = /* @__PURE__ */ new WeakMap();
508
+ /**
509
+ * The uncached conversion behind {@link renderFetchOutput}. Separated so the
510
+ * memo wraps exactly one call site and the conversion logic stays pure.
511
+ *
512
+ * @param result - the seam's fetch outcome.
513
+ * @param maxOutputChars - cap on the complete returned string.
514
+ * @returns the bounded text and effective truncation.
515
+ */
516
+ function computeFetchOutput(result, maxOutputChars) {
517
+ const header = `Fetched ${result.url} (HTTP ${result.statusCode})\n\n`;
518
+ const rendered = renderBody(result.body, maxOutputChars);
519
+ const prefix = `${header}${rendered.text}`;
520
+ const truncated = result.truncated || rendered.sourceTruncated || prefix.length > maxOutputChars;
521
+ const full = `${prefix}${truncated ? TRUNCATION_FOOTER : ""}`;
522
+ if (full.length <= maxOutputChars) return {
523
+ text: full,
524
+ truncated
525
+ };
526
+ if (maxOutputChars < 78) return {
527
+ text: full.slice(0, maxOutputChars),
528
+ truncated
529
+ };
530
+ return {
531
+ text: `${prefix.slice(0, maxOutputChars - 78)}${TRUNCATION_FOOTER}`,
532
+ truncated
533
+ };
534
+ }
535
+ /**
536
+ * Format a fetch result as one model-facing text block, bounded as a whole.
537
+ *
538
+ * @param result - the seam's fetch outcome.
539
+ * @param maxOutputChars - cap on the complete returned string.
540
+ * @returns the complete text from {@link renderFetchOutput}.
541
+ */
542
+ function formatFetchOutput(result, maxOutputChars) {
543
+ return renderFetchOutput(result, maxOutputChars).text;
544
+ }
545
+ /**
546
+ * Pending-call presentation: a fetch card titled by the URL.
547
+ *
548
+ * @param args - the raw tool arguments; only `url` feeds the view.
549
+ * @returns the generic card view (`kind: 'fetch'`) shown while the call runs.
550
+ */
551
+ function presentFetchCall(args) {
552
+ return {
553
+ card: "generic",
554
+ title: args.url,
555
+ kind: "fetch",
556
+ rawInput: args.url
557
+ };
558
+ }
559
+ /**
560
+ * Project a validated `web_fetch` output value into its replayable presentation
561
+ * meta ({@link WebFetchMeta} as opaque JSON). `truncated` is the effective
562
+ * truncation the model-facing text reflects (via {@link renderFetchOutput}), not
563
+ * the provider-only `WebFetchResult.truncated`, so the fetch card never disagrees
564
+ * with the returned text.
565
+ *
566
+ * @param value - the canonical `web_fetch` output value (the seam's result shape).
567
+ * @param maxOutputChars - the deployment's output cap, the same one
568
+ * {@link formatFetchOutput} applies to the render text.
569
+ * @returns the URL, status code, and effective truncation flag.
570
+ */
571
+ function fetchMetaFromValue(value, maxOutputChars) {
572
+ return {
573
+ url: value.url,
574
+ statusCode: value.statusCode,
575
+ truncated: renderFetchOutput(value, maxOutputChars).truncated
576
+ };
577
+ }
578
+ /**
579
+ * Narrow opaque live or replayed result metadata to a {@link WebFetchMeta}.
580
+ * Malformed metadata returns `undefined` so presentation can fall back to the
581
+ * generic card instead of throwing during replay.
582
+ *
583
+ * @param meta - result metadata.
584
+ * @returns the validated fetch meta, or `undefined` for absent or malformed data.
585
+ */
586
+ function fetchMetaFromResult(meta) {
587
+ if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return void 0;
588
+ const { url, statusCode, truncated } = meta;
589
+ if (typeof url !== "string" || typeof statusCode !== "number" || typeof truncated !== "boolean") return void 0;
590
+ return {
591
+ url,
592
+ statusCode,
593
+ truncated
594
+ };
595
+ }
596
+ /**
597
+ * Completed-call presentation: a `web` fetch card carrying the retrieval summary
598
+ * from `meta`. It sets no `content` copy — a UI without the `web` capability
599
+ * falls back to the raw `tool/result` content, the already-markdown body (see the
600
+ * web-result-card Agent Note).
601
+ *
602
+ * @param args - the raw tool arguments; `url` becomes the result-state title so a
603
+ * window-truncated replay that dropped the call head still has one.
604
+ * @param result - the final model-facing tool result; `meta` carries the summary.
605
+ * @returns the fetch result view, or `undefined` (generic card) on failure or
606
+ * malformed meta.
607
+ */
608
+ function presentFetchResult(args, result) {
609
+ if (result.isError) return void 0;
610
+ const meta = fetchMetaFromResult(result.meta);
611
+ if (meta === void 0) return void 0;
612
+ return {
613
+ card: "web",
614
+ kind: "fetch",
615
+ title: args.url,
616
+ url: meta.url,
617
+ statusCode: meta.statusCode,
618
+ truncated: meta.truncated
619
+ };
620
+ }
621
+ /**
622
+ * Register the `web_fetch` tool and its system-prompt guidance.
623
+ *
624
+ * @param ctx - context whose `tools` and `systemPrompt` registries receive the
625
+ * registrations; both are effect-scoped and unregister on plugin dispose.
626
+ * @param timeoutMs - the cooperative tool-call budget (ms) attached as the tool's
627
+ * `ToolDefinition.timeoutMs` for `@deepseek-ai/dsh-timeout-policy` to enforce.
628
+ * @param maxOutputChars - cap on the complete rendered tool output (see
629
+ * {@link formatFetchOutput}) and on source characters converted synchronously.
630
+ */
631
+ function applyWebFetchTool(ctx, timeoutMs, maxOutputChars) {
632
+ ctx.systemPrompt.section({
633
+ name: "tool:web_fetch",
634
+ order: 111,
635
+ 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."
636
+ });
637
+ ctx.tools.register(defineTool({
638
+ name: "web_fetch",
639
+ description: "Fetch the content of a specific HTTP(S) URL and return it decoded to text.",
640
+ parameters: { url: {
641
+ type: "string",
642
+ required: true,
643
+ description: "The HTTP(S) URL to fetch."
644
+ } },
645
+ output: {
646
+ schema: {
647
+ type: "object",
648
+ additionalProperties: false,
649
+ properties: {
650
+ url: {
651
+ type: "string",
652
+ required: true
653
+ },
654
+ statusCode: {
655
+ type: "integer",
656
+ required: true
657
+ },
658
+ body: {
659
+ required: true,
660
+ oneOf: [{
661
+ type: "object",
662
+ additionalProperties: false,
663
+ properties: {
664
+ kind: {
665
+ type: "string",
666
+ required: true,
667
+ const: "html"
668
+ },
669
+ content: {
670
+ type: "string",
671
+ required: true
672
+ }
673
+ }
674
+ }, {
675
+ type: "object",
676
+ additionalProperties: false,
677
+ properties: {
678
+ kind: {
679
+ type: "string",
680
+ required: true,
681
+ const: "text"
682
+ },
683
+ content: {
684
+ type: "string",
685
+ required: true
686
+ }
687
+ }
688
+ }]
689
+ },
690
+ truncated: {
691
+ type: "boolean",
692
+ required: true
693
+ }
694
+ }
695
+ },
696
+ render: (_args, value) => [{
697
+ type: "text",
698
+ text: formatFetchOutput(value, maxOutputChars)
699
+ }],
700
+ presentationMeta: (_args, value) => fetchMetaFromValue(value, maxOutputChars)
701
+ },
702
+ timeoutMs,
703
+ isConcurrencySafe: () => true,
704
+ async execute(args, exec) {
705
+ const input = parseFetchArgs(args);
706
+ const result = await ctx.web.fetch({ url: input.url }, exec.signal);
707
+ return {
708
+ url: result.url,
709
+ statusCode: result.statusCode,
710
+ body: {
711
+ kind: result.body.kind,
712
+ content: result.body.content
713
+ },
714
+ truncated: result.truncated
715
+ };
716
+ },
717
+ presentCall: presentFetchCall,
718
+ presentResult: (args, result) => presentFetchResult(args, result)
719
+ }));
720
+ }
721
+ //#endregion
722
+ //#region lib/types/index.js
723
+ /**
724
+ * Model-facing `web_search` and `web_fetch` tools over `ctx.web`. This package owns schemas,
725
+ * validation, prompt guidance, limits, and presentation, never concrete providers. Enablement
726
+ * controls tool registration; an enabled tool remains visible when its provider is unavailable
727
+ * and fails with a structured error at execution time.
728
+ * @module @deepseek-ai/dsh-tool-web
729
+ */
730
+ /** Cordis plugin name used by loader diagnostics. */
731
+ const name = "tool-web";
732
+ /** Services required by the web tool suite. */
733
+ const inject = [
734
+ "tools",
735
+ "web",
736
+ "systemPrompt"
737
+ ];
738
+ /** Default cooperative tool-call timeout budget (ms) for the web tools. */
739
+ const DEFAULT_WEB_TOOL_TIMEOUT_MS = 3e4;
740
+ /**
741
+ * Default cap on one `web_fetch` output and on source characters converted
742
+ * synchronously. This leaves headroom above the local provider's default
743
+ * 100,000-character body cap while bounding custom providers and rendered output.
744
+ */
745
+ const DEFAULT_FETCH_MAX_OUTPUT_CHARS = 2e5;
746
+ const Config = z.object({
747
+ search: z.boolean().default(true),
748
+ fetch: z.boolean().default(true),
749
+ searchMaxResults: z.number().default(8),
750
+ fetchTimeoutMs: z.number().default(DEFAULT_WEB_TOOL_TIMEOUT_MS),
751
+ searchTimeoutMs: z.number().default(DEFAULT_WEB_TOOL_TIMEOUT_MS),
752
+ fetchMaxOutputChars: z.number().default(DEFAULT_FETCH_MAX_OUTPUT_CHARS)
753
+ });
754
+ /** Configured count, timeout, and character caps must be positive integers. */
755
+ function assertPositiveInteger(name, value) {
756
+ if (!Number.isInteger(value) || value < 1) throw new Error(`tool-web: ${name} must be a positive integer`);
757
+ }
758
+ /**
759
+ * Register the enabled web tools. `search`/`fetch` default to true; a product
760
+ * that wants only one disables the other in config. Each tool's cooperative
761
+ * timeout budget (`fetchTimeoutMs`/`searchTimeoutMs`, default 30000) is resolved
762
+ * here and attached to the tool as `ToolDefinition.timeoutMs` for
763
+ * `@deepseek-ai/dsh-timeout-policy` to enforce. The tools' disposers are
764
+ * fiber-scoped (the effect-based registries clean up on dispose), so no manual
765
+ * teardown is needed.
766
+ */
767
+ function apply(ctx, config) {
768
+ const resolved = config;
769
+ assertPositiveInteger("searchMaxResults", resolved.searchMaxResults);
770
+ assertPositiveInteger("fetchTimeoutMs", resolved.fetchTimeoutMs);
771
+ assertPositiveInteger("searchTimeoutMs", resolved.searchTimeoutMs);
772
+ assertPositiveInteger("fetchMaxOutputChars", resolved.fetchMaxOutputChars);
773
+ if (resolved.search) applyWebSearchTool(ctx, resolved.searchMaxResults, resolved.searchTimeoutMs, resolved.fetch);
774
+ if (resolved.fetch) applyWebFetchTool(ctx, resolved.fetchTimeoutMs, resolved.fetchMaxOutputChars);
775
+ }
776
+ //#endregion
777
+ export { Config, DEFAULT_FETCH_MAX_OUTPUT_CHARS, DEFAULT_WEB_TOOL_TIMEOUT_MS, WEB_SEARCH_MAX_RESULTS, apply, applyWebFetchTool, applyWebSearchTool, fetchMetaFromResult, fetchMetaFromValue, formatFetchOutput, formatSearchOutput, inject, name, parseFetchArgs, parseSearchArgs, presentFetchCall, presentFetchResult, presentSearchCall, presentSearchResult, searchMetaFromResult, searchMetaFromValue };