@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/LICENSE +28 -0
- package/README.i18n.yaml +6 -0
- package/README.md +138 -0
- package/README.zh.md +138 -0
- package/lib/index.js +777 -0
- package/lib/invariant.js +23 -0
- package/lib/types/fetch.d.ts +108 -0
- package/lib/types/index.d.ts +52 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/search.d.ts +108 -0
- package/package.json +63 -0
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 };
|