@iinm/plain-agent 1.9.4 → 1.10.0

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.
@@ -0,0 +1,503 @@
1
+ /**
2
+ * @import { Tool } from '../tool'
3
+ * @import { CallModel } from '../model'
4
+ */
5
+
6
+ import { execFile } from "node:child_process";
7
+ import { styleText } from "node:util";
8
+ import { getGoogleCloudAccessToken } from "../providers/platform/googleCloud.mjs";
9
+ import { noThrow } from "../utils/noThrow.mjs";
10
+
11
+ /**
12
+ * @typedef {WebSearchToolGeminiOptions
13
+ * | WebSearchToolGeminiVertexAIOptions
14
+ * | WebSearchToolCommandOptions} WebSearchToolOptions
15
+ */
16
+
17
+ /**
18
+ * @typedef {Object} WebSearchToolGeminiOptions
19
+ * @property {"gemini"} provider
20
+ * @property {string=} baseURL
21
+ * @property {string} apiKey
22
+ * @property {string} model
23
+ */
24
+
25
+ /**
26
+ * @typedef {Object} WebSearchToolGeminiVertexAIOptions
27
+ * @property {"gemini-vertex-ai"} provider
28
+ * @property {string} baseURL
29
+ * @property {string=} account
30
+ * @property {string} model
31
+ */
32
+
33
+ /**
34
+ * Runtime configuration for the `command` provider.
35
+ *
36
+ * Runs `command` once per keyword set with `args` followed by the keywords
37
+ * (no shell). `modelCaller` is injected by the caller (e.g., `main.mjs`)
38
+ * using the agent's main model and is used to distil the combined results
39
+ * down to entries relevant to `question`.
40
+ *
41
+ * @typedef {Object} WebSearchToolCommandOptions
42
+ * @property {"command"} provider
43
+ * @property {string} command Executable used to perform each search (e.g., a wrapper around a search API).
44
+ * @property {string[]} args Arguments passed before the keywords (e.g., `["-n", "5"]`).
45
+ * @property {number=} timeoutMs Per-search timeout in milliseconds (default 30000).
46
+ * @property {Record<string, string>=} env Extra environment variables, merged on top of PATH / HOME / LANG.
47
+ * @property {CallModel} modelCaller
48
+ * @property {number=} maxLengthPerSearch Truncate each search's output to this many characters (default 50000).
49
+ * @property {number=} maxTotalLength Truncate the combined output across searches to this many characters (default 200000).
50
+ */
51
+
52
+ /**
53
+ * @typedef {Object} WebSearch
54
+ * @property {string[]} keywords
55
+ */
56
+
57
+ /**
58
+ * @typedef {Object} WebSearchInput
59
+ * @property {WebSearch[]} searches
60
+ * @property {string} question
61
+ */
62
+
63
+ /** @type {number} */
64
+ const DEFAULT_MAX_LENGTH_PER_SEARCH = 50_000;
65
+
66
+ /** @type {number} */
67
+ const DEFAULT_MAX_TOTAL_LENGTH = 200_000;
68
+
69
+ /** @type {number} */
70
+ const DEFAULT_SEARCH_TIMEOUT_MS = 30_000;
71
+
72
+ /** @type {number} */
73
+ const SEARCH_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
74
+
75
+ /**
76
+ * @param {WebSearchToolOptions} config
77
+ * @returns {Tool}
78
+ */
79
+ export function createWebSearchTool(config) {
80
+ return {
81
+ def: {
82
+ name: "web_search",
83
+ description:
84
+ "Run one or more web searches and answer a question based on the combined results.",
85
+ inputSchema: {
86
+ type: "object",
87
+ properties: {
88
+ searches: {
89
+ type: "array",
90
+ description:
91
+ "One or more searches to run. Each entry describes a single query.",
92
+ items: {
93
+ type: "object",
94
+ properties: {
95
+ keywords: {
96
+ type: "array",
97
+ description: "Keywords for this search query.",
98
+ items: { type: "string" },
99
+ },
100
+ },
101
+ required: ["keywords"],
102
+ },
103
+ },
104
+ question: {
105
+ type: "string",
106
+ description:
107
+ "The question that the combined search results should answer.",
108
+ },
109
+ },
110
+ required: ["searches", "question"],
111
+ },
112
+ },
113
+
114
+ /**
115
+ * @param {WebSearchInput} input
116
+ * @returns {Promise<string | Error>}
117
+ */
118
+ impl: async (input) =>
119
+ await noThrow(async () => {
120
+ const validationError = validateInput(input);
121
+ if (validationError) {
122
+ return validationError;
123
+ }
124
+ switch (config.provider) {
125
+ case "gemini":
126
+ case "gemini-vertex-ai":
127
+ return webSearchViaGemini(config, input, 0);
128
+ case "command":
129
+ return webSearchViaCommand(config, input);
130
+ }
131
+ }),
132
+
133
+ /**
134
+ * @param {Record<string, unknown>} _input
135
+ * @returns {Record<string, unknown>}
136
+ */
137
+ maskApprovalInput: (_input) => {
138
+ return {};
139
+ },
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Truncate `content` to at most `maxLength` characters, keeping the head.
145
+ * When truncation occurs, a `[truncated: ...]` marker is appended.
146
+ *
147
+ * @param {string} content
148
+ * @param {number} maxLength
149
+ * @returns {{ text: string, truncated: boolean, originalLength: number }}
150
+ */
151
+ export function truncateText(content, maxLength) {
152
+ if (content.length <= maxLength) {
153
+ return { text: content, truncated: false, originalLength: content.length };
154
+ }
155
+ const head = content.slice(0, maxLength);
156
+ const truncatedLength = content.length - maxLength;
157
+ return {
158
+ text: `${head}\n\n[truncated: ${truncatedLength} of ${content.length} chars omitted]`,
159
+ truncated: true,
160
+ originalLength: content.length,
161
+ };
162
+ }
163
+
164
+ /**
165
+ * @param {WebSearchInput} input
166
+ * @returns {Error | null}
167
+ */
168
+ function validateInput(input) {
169
+ if (!Array.isArray(input.searches) || input.searches.length === 0) {
170
+ return new Error("`searches` is required and must be a non-empty array.");
171
+ }
172
+ for (const search of input.searches) {
173
+ if (!search || typeof search !== "object" || Array.isArray(search)) {
174
+ return new Error(
175
+ "Each entry in `searches` must be an object with a `keywords` field.",
176
+ );
177
+ }
178
+ if (
179
+ !Array.isArray(search.keywords) ||
180
+ search.keywords.length === 0 ||
181
+ search.keywords.some((k) => typeof k !== "string" || k.length === 0)
182
+ ) {
183
+ return new Error(
184
+ "Each search's `keywords` must be a non-empty array of non-empty strings.",
185
+ );
186
+ }
187
+ }
188
+ if (!input.question || typeof input.question !== "string") {
189
+ return new Error("`question` is required and must be a string.");
190
+ }
191
+ return null;
192
+ }
193
+
194
+ /**
195
+ * @param {WebSearchToolCommandOptions} config
196
+ * @param {WebSearchInput} input
197
+ * @returns {Promise<string | Error>}
198
+ */
199
+ async function webSearchViaCommand(config, input) {
200
+ const maxLengthPerSearch =
201
+ config.maxLengthPerSearch ?? DEFAULT_MAX_LENGTH_PER_SEARCH;
202
+ const maxTotalLength = config.maxTotalLength ?? DEFAULT_MAX_TOTAL_LENGTH;
203
+
204
+ /** @type {{ keywords: string[], text: string, truncated: boolean, originalLength: number, error?: string }[]} */
205
+ const results = [];
206
+ for (const search of input.searches) {
207
+ try {
208
+ const raw = await runSearchCommand(config, search.keywords);
209
+ const { text, truncated, originalLength } = truncateText(
210
+ raw,
211
+ maxLengthPerSearch,
212
+ );
213
+ results.push({
214
+ keywords: search.keywords,
215
+ text,
216
+ truncated,
217
+ originalLength,
218
+ });
219
+ } catch (err) {
220
+ const message = err instanceof Error ? err.message : String(err);
221
+ console.error(styleText("yellow", message));
222
+ results.push({
223
+ keywords: search.keywords,
224
+ text: "",
225
+ truncated: false,
226
+ originalLength: 0,
227
+ error: message,
228
+ });
229
+ }
230
+ }
231
+
232
+ const successCount = results.filter((r) => !r.error).length;
233
+ if (successCount === 0) {
234
+ return new Error(
235
+ `Failed to run any search via ${config.command}:\n${results
236
+ .map((r) => `- [${r.keywords.join(" ")}]: ${r.error ?? "unknown"}`)
237
+ .join("\n")}`,
238
+ );
239
+ }
240
+
241
+ const sections = results.map((r) => {
242
+ const keywordsAttr = r.keywords.join(" ").replace(/"/g, "'");
243
+ if (r.error) {
244
+ return `<search keywords="${keywordsAttr}" error="${r.error.replace(/"/g, "'")}"></search>`;
245
+ }
246
+ const attrs = r.truncated
247
+ ? ` truncated="true" original_length="${r.originalLength}"`
248
+ : "";
249
+ return `<search keywords="${keywordsAttr}"${attrs}>\n${r.text}\n</search>`;
250
+ });
251
+
252
+ const joined = sections.join("\n\n");
253
+ const totalCap = truncateText(joined, maxTotalLength);
254
+
255
+ const searchCommandDisplay = [
256
+ config.command,
257
+ ...config.args,
258
+ "<KEYWORDS...>",
259
+ ].join(" ");
260
+ const systemPrompt = [
261
+ "You distil multiple web-search results into entries relevant to the user's question.",
262
+ `Each search's raw output is wrapped in a <search keywords="..."> tag and was produced by \`${searchCommandDisplay}\`.`,
263
+ 'Some searches may be marked truncated="true"; treat those as partial.',
264
+ "Discard unrelated entries; keep only what helps answer the question.",
265
+ "Preserve any source URLs from the raw output and cite them inline (e.g., [1], [2]).",
266
+ "If none of the results are relevant, say so explicitly rather than guessing.",
267
+ ].join(" ");
268
+
269
+ const userPrompt = [
270
+ `Question: ${input.question}`,
271
+ "",
272
+ "Searches run:",
273
+ ...input.searches.map((s, i) => `- [${i + 1}] ${s.keywords.join(" ")}`),
274
+ "",
275
+ "Search results:",
276
+ totalCap.text,
277
+ ].join("\n");
278
+
279
+ const modelResult = await config.modelCaller({
280
+ messages: [
281
+ {
282
+ role: "system",
283
+ content: [{ type: "text", text: systemPrompt }],
284
+ },
285
+ {
286
+ role: "user",
287
+ content: [{ type: "text", text: userPrompt }],
288
+ },
289
+ ],
290
+ });
291
+
292
+ if (modelResult instanceof Error) {
293
+ return modelResult;
294
+ }
295
+
296
+ const answerText = modelResult.message.content
297
+ .map((part) => (part.type === "text" ? part.text : ""))
298
+ .join("")
299
+ .trim();
300
+
301
+ const summaryList = results
302
+ .map((r, i) => {
303
+ const suffix = r.error
304
+ ? ` (error: ${r.error})`
305
+ : r.truncated
306
+ ? " (truncated)"
307
+ : "";
308
+ return `- [${i + 1}] ${r.keywords.join(" ")}${suffix}`;
309
+ })
310
+ .join("\n");
311
+
312
+ return [answerText, summaryList].join("\n\n");
313
+ }
314
+
315
+ /**
316
+ * @param {WebSearchToolGeminiOptions | WebSearchToolGeminiVertexAIOptions} config
317
+ * @param {WebSearchInput} input
318
+ * @param {number} retryCount
319
+ * @returns {Promise<string | Error>}
320
+ */
321
+ async function webSearchViaGemini(config, input, retryCount) {
322
+ const model = config.model ?? "gemini-3-flash-preview";
323
+ const url =
324
+ config.provider === "gemini-vertex-ai"
325
+ ? `${config.baseURL}/publishers/google/models/${config.model}:generateContent`
326
+ : config.baseURL
327
+ ? `${config.baseURL}/models/${model}:generateContent`
328
+ : `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`;
329
+
330
+ /** @type {Record<string,string>} */
331
+ const authHeader =
332
+ config.provider === "gemini-vertex-ai"
333
+ ? {
334
+ Authorization: `Bearer ${await getGoogleCloudAccessToken(config.account)}`,
335
+ }
336
+ : {
337
+ "x-goog-api-key": config.apiKey ?? "",
338
+ };
339
+
340
+ const keywordsHint = input.searches
341
+ .map((s, i) => `- [${i + 1}] ${s.keywords.join(" ")}`)
342
+ .join("\n");
343
+
344
+ const data = {
345
+ contents: [
346
+ {
347
+ role: "user",
348
+ parts: [
349
+ {
350
+ text: `I need a comprehensive answer to this question. Please note that I don't have access to external URLs, so include all relevant facts, data, or explanations directly in your response. Avoid referencing links I can't open.
351
+
352
+ Suggested search queries (one per line):
353
+ ${keywordsHint}
354
+
355
+ Question: ${input.question}`,
356
+ },
357
+ ],
358
+ },
359
+ ],
360
+ tools: [
361
+ {
362
+ google_search: {},
363
+ },
364
+ ],
365
+ };
366
+
367
+ const response = await fetch(url, {
368
+ method: "POST",
369
+ headers: {
370
+ ...authHeader,
371
+ "Content-Type": "application/json",
372
+ },
373
+ body: JSON.stringify(data),
374
+ signal: AbortSignal.timeout(120 * 1000),
375
+ });
376
+
377
+ if (response.status === 429 || response.status >= 500) {
378
+ const interval = Math.min(2 * 2 ** retryCount, 16);
379
+ console.error(
380
+ styleText(
381
+ "yellow",
382
+ `Google API returned ${response.status}. Retrying in ${interval} seconds...`,
383
+ ),
384
+ );
385
+ await new Promise((resolve) => setTimeout(resolve, interval * 1000));
386
+ return webSearchViaGemini(config, input, retryCount + 1);
387
+ }
388
+
389
+ if (!response.ok) {
390
+ return new Error(
391
+ `Failed to search the web: status=${response.status}, body=${await response.text()}`,
392
+ );
393
+ }
394
+
395
+ const body = await response.json();
396
+
397
+ const candidate = body.candidates?.[0];
398
+ const text = candidate?.content?.parts?.[0]?.text;
399
+ /** @type {{segment?:{startIndex:number,endIndex:number,text:string},groundingChunkIndices?:number[]}[] | undefined} */
400
+ const supports = candidate?.groundingMetadata?.groundingSupports;
401
+ /** @type {{web?:{uri:string,title:string}}[] | undefined} */
402
+ const chunks = candidate?.groundingMetadata?.groundingChunks;
403
+
404
+ if (typeof text !== "string") {
405
+ return new Error(
406
+ `Unexpected response format from Google: ${JSON.stringify(body)}`,
407
+ );
408
+ }
409
+
410
+ // Sort by end_index desc because Gemini grounding indexes are byte offsets
411
+ // into the original UTF-8 text.
412
+ const sortedSupports = supports?.toSorted(
413
+ (a, b) => (b.segment?.endIndex ?? 0) - (a.segment?.endIndex ?? 0),
414
+ );
415
+
416
+ // Insert citations using UTF-8 byte offsets.
417
+ let textWithCitations = text;
418
+ for (const support of sortedSupports ?? []) {
419
+ const endIndex = support.segment?.endIndex;
420
+ if (
421
+ typeof endIndex !== "number" ||
422
+ !support.groundingChunkIndices?.length
423
+ ) {
424
+ continue;
425
+ }
426
+
427
+ textWithCitations = insertTextAtUtf8ByteIndex(
428
+ textWithCitations,
429
+ endIndex,
430
+ ` [${support.groundingChunkIndices.map((i) => i + 1).join(", ")}] `,
431
+ );
432
+ }
433
+
434
+ const chunkString = (chunks ?? [])
435
+ .map(
436
+ (chunk, index) =>
437
+ `- [${index + 1} - ${chunk.web?.title}](${chunk.web?.uri})`,
438
+ )
439
+ .join("\n");
440
+
441
+ return [textWithCitations, chunkString].join("\n\n");
442
+ }
443
+
444
+ /**
445
+ * Run `command` with `args` followed by the keywords and return stdout.
446
+ *
447
+ * The process is spawned directly (no shell). When `command` exits with a
448
+ * non-zero status, the resulting error message includes the keywords and any
449
+ * captured stderr to aid diagnosis.
450
+ *
451
+ * @param {WebSearchToolCommandOptions} config
452
+ * @param {string[]} keywords
453
+ * @returns {Promise<string>}
454
+ */
455
+ function runSearchCommand(config, keywords) {
456
+ return new Promise((resolve, reject) => {
457
+ execFile(
458
+ config.command,
459
+ [...config.args, ...keywords],
460
+ {
461
+ shell: false,
462
+ env: {
463
+ PATH: process.env.PATH,
464
+ HOME: process.env.HOME,
465
+ LANG: process.env.LANG,
466
+ ...(config.env ?? {}),
467
+ },
468
+ timeout: config.timeoutMs ?? DEFAULT_SEARCH_TIMEOUT_MS,
469
+ maxBuffer: SEARCH_MAX_BUFFER_BYTES,
470
+ },
471
+ (err, stdout, stderr) => {
472
+ if (err) {
473
+ reject(
474
+ new Error(
475
+ `${config.command} failed for [${keywords.join(" ")}]: ${err.message}${stderr ? `\n${stderr}` : ""}`,
476
+ ),
477
+ );
478
+ return;
479
+ }
480
+ resolve(stdout);
481
+ },
482
+ );
483
+ });
484
+ }
485
+
486
+ /**
487
+ * @param {string} source
488
+ * @param {number} byteIndex
489
+ * @param {string} insertText
490
+ */
491
+ function insertTextAtUtf8ByteIndex(source, byteIndex, insertText) {
492
+ const sourceBuffer = Buffer.from(source, "utf8");
493
+ const normalizedByteIndex = Math.max(
494
+ 0,
495
+ Math.min(byteIndex, sourceBuffer.length),
496
+ );
497
+
498
+ return Buffer.concat([
499
+ sourceBuffer.subarray(0, normalizedByteIndex),
500
+ Buffer.from(insertText, "utf8"),
501
+ sourceBuffer.subarray(normalizedByteIndex),
502
+ ]).toString("utf8");
503
+ }
@@ -1,209 +0,0 @@
1
- /**
2
- * @import { Tool } from '../tool'
3
- */
4
-
5
- import { styleText } from "node:util";
6
- import { getGoogleCloudAccessToken } from "../providers/platform/googleCloud.mjs";
7
- import { noThrow } from "../utils/noThrow.mjs";
8
-
9
- /** @typedef {AskURLToolGeminiOptions | AskURLToolGeminiVertexAIOptions} AskURLToolOptions */
10
-
11
- /**
12
- * @typedef {Object} AskURLToolGeminiOptions
13
- * @property {"gemini"} provider
14
- * @property {string=} baseURL
15
- * @property {string} apiKey
16
- * @property {string} model
17
- */
18
-
19
- /**
20
- * @typedef {Object} AskURLToolGeminiVertexAIOptions
21
- * @property {"gemini-vertex-ai"} provider
22
- * @property {string} baseURL
23
- * @property {string=} account
24
- * @property {string} model
25
- */
26
-
27
- /**
28
- * @typedef {Object} AskURLInput
29
- * @property {string} question
30
- */
31
-
32
- /**
33
- * @param {AskURLToolOptions} config
34
- * @returns {Tool}
35
- */
36
- export function createAskURLTool(config) {
37
- /**
38
- * @param {AskURLInput} input
39
- * @param {number} retryCount
40
- * @returns {Promise<string | Error>}
41
- */
42
- async function askURL(input, retryCount = 0) {
43
- const model = config.model ?? "gemini-3-flash-preview";
44
- const url =
45
- config.provider === "gemini-vertex-ai"
46
- ? `${config.baseURL}/publishers/google/models/${config.model}:generateContent`
47
- : config.baseURL
48
- ? `${config.baseURL}/models/${model}:generateContent`
49
- : `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`;
50
-
51
- /** @type {Record<string,string>} */
52
- const authHeader =
53
- config.provider === "gemini-vertex-ai"
54
- ? {
55
- Authorization: `Bearer ${await getGoogleCloudAccessToken(config.account)}`,
56
- }
57
- : {
58
- "x-goog-api-key": config.apiKey ?? "",
59
- };
60
-
61
- const data = {
62
- contents: [
63
- {
64
- role: "user",
65
- parts: [
66
- {
67
- text: `I need a comprehensive answer to this question. Please note that I don't have access to external URLs, so include all relevant facts, data, or explanations directly in your response. Avoid referencing links I can't open.
68
-
69
- Question: ${input.question}`,
70
- },
71
- ],
72
- },
73
- ],
74
- tools: [
75
- {
76
- url_context: {},
77
- },
78
- ],
79
- };
80
-
81
- const response = await fetch(url, {
82
- method: "POST",
83
- headers: {
84
- ...authHeader,
85
- "Content-Type": "application/json",
86
- },
87
- body: JSON.stringify(data),
88
- signal: AbortSignal.timeout(120 * 1000),
89
- });
90
-
91
- if (response.status === 429 || response.status >= 500) {
92
- const interval = Math.min(2 * 2 ** retryCount, 16);
93
- console.error(
94
- styleText(
95
- "yellow",
96
- `Google API returned ${response.status}. Retrying in ${interval} seconds...`,
97
- ),
98
- );
99
- await new Promise((resolve) => setTimeout(resolve, interval * 1000));
100
- return askURL(input, retryCount + 1);
101
- }
102
-
103
- if (!response.ok) {
104
- return new Error(
105
- `Failed to ask Web: status=${response.status}, body=${await response.text()}`,
106
- );
107
- }
108
-
109
- const body = await response.json();
110
-
111
- const candidate = body.candidates?.[0];
112
- const text = candidate?.content?.parts?.[0]?.text;
113
- /** @type {{segment?:{startIndex:number,endIndex:number,text:string},groundingChunkIndices?:number[]}[] | undefined} */
114
- const supports = candidate?.groundingMetadata?.groundingSupports;
115
- /** @type {{web?:{uri:string,title:string}}[] | undefined} */
116
- const chunks = candidate?.groundingMetadata?.groundingChunks;
117
-
118
- if (typeof text !== "string") {
119
- return new Error(
120
- `Unexpected response format from Google: ${JSON.stringify(body)}`,
121
- );
122
- }
123
-
124
- /**
125
- * @param {string} source
126
- * @param {number} byteIndex
127
- * @param {string} insertText
128
- */
129
- const insertTextAtUtf8ByteIndex = (source, byteIndex, insertText) => {
130
- const sourceBuffer = Buffer.from(source, "utf8");
131
- const normalizedByteIndex = Math.max(
132
- 0,
133
- Math.min(byteIndex, sourceBuffer.length),
134
- );
135
-
136
- return Buffer.concat([
137
- sourceBuffer.subarray(0, normalizedByteIndex),
138
- Buffer.from(insertText, "utf8"),
139
- sourceBuffer.subarray(normalizedByteIndex),
140
- ]).toString("utf8");
141
- };
142
-
143
- // Sort by end_index desc because Gemini grounding indexes are byte offsets
144
- // into the original UTF-8 text.
145
- const sortedSupports = supports?.toSorted(
146
- (a, b) => (b.segment?.endIndex ?? 0) - (a.segment?.endIndex ?? 0),
147
- );
148
-
149
- // Insert citations using UTF-8 byte offsets.
150
- let textWithCitations = text;
151
- for (const support of sortedSupports ?? []) {
152
- const endIndex = support.segment?.endIndex;
153
- if (
154
- typeof endIndex !== "number" ||
155
- !support.groundingChunkIndices?.length
156
- ) {
157
- continue;
158
- }
159
-
160
- textWithCitations = insertTextAtUtf8ByteIndex(
161
- textWithCitations,
162
- endIndex,
163
- ` [${support.groundingChunkIndices.map((i) => i + 1).join(", ")}] `,
164
- );
165
- }
166
-
167
- const chunkString = (chunks ?? [])
168
- .map(
169
- (chunk, index) =>
170
- `- [${index + 1} - ${chunk.web?.title}](${chunk.web?.uri})`,
171
- )
172
- .join("\n");
173
-
174
- return [textWithCitations, chunkString].join("\n\n");
175
- }
176
-
177
- return {
178
- def: {
179
- name: "ask_url",
180
- description:
181
- "Use one or more provided URLs to answer a question. Include the URLs in your question.",
182
- inputSchema: {
183
- type: "object",
184
- properties: {
185
- question: {
186
- type: "string",
187
- description:
188
- "The question to ask, including one or more URLs to use as context.",
189
- },
190
- },
191
- required: ["question"],
192
- },
193
- },
194
-
195
- /**
196
- * @param {AskURLInput} input
197
- * @returns {Promise<string | Error>}
198
- */
199
- impl: async (input) => await noThrow(async () => askURL(input, 0)),
200
-
201
- /**
202
- * @param {Record<string, unknown>} _input
203
- * @returns {Record<string, unknown>}
204
- */
205
- maskApprovalInput: (_input) => {
206
- return {};
207
- },
208
- };
209
- }