@iinm/plain-agent 1.9.4 → 1.10.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.
@@ -0,0 +1,442 @@
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 {WebFetchToolGeminiOptions
13
+ * | WebFetchToolGeminiVertexAIOptions
14
+ * | WebFetchToolCommandOptions} WebFetchToolOptions
15
+ */
16
+
17
+ /**
18
+ * @typedef {Object} WebFetchToolGeminiOptions
19
+ * @property {"gemini"} provider
20
+ * @property {string=} baseURL
21
+ * @property {string} apiKey
22
+ * @property {string} model
23
+ */
24
+
25
+ /**
26
+ * @typedef {Object} WebFetchToolGeminiVertexAIOptions
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` with `args` followed by the URL (one process per call, no
37
+ * shell). `modelCaller` is injected by the caller (e.g., `main.mjs`) using
38
+ * the agent's main model.
39
+ *
40
+ * @typedef {Object} WebFetchToolCommandOptions
41
+ * @property {"command"} provider
42
+ * @property {string} command Executable used to fetch the URL (e.g., `"w3m"`, `"curl"`).
43
+ * @property {string[]} args Arguments passed before the URL (e.g., `["-dump"]`).
44
+ * @property {number=} timeoutMs Per-call timeout in milliseconds (default 30000).
45
+ * @property {Record<string, string>=} env Extra environment variables, merged on top of PATH / HOME / LANG.
46
+ * @property {CallModel} modelCaller
47
+ * @property {number=} maxLength Truncate fetched content to this many characters (default 200000).
48
+ */
49
+
50
+ /**
51
+ * @typedef {Object} WebFetchInput
52
+ * @property {string} url
53
+ * @property {string} question
54
+ */
55
+
56
+ /** @type {number} */
57
+ const DEFAULT_MAX_LENGTH = 200_000;
58
+
59
+ /** @type {number} */
60
+ const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
61
+
62
+ /** @type {number} */
63
+ const FETCH_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
64
+
65
+ /**
66
+ * @param {WebFetchToolOptions} config
67
+ * @returns {Tool}
68
+ */
69
+ export function createWebFetchTool(config) {
70
+ return {
71
+ def: {
72
+ name: "web_fetch",
73
+ description:
74
+ "Fetch the contents of a single URL and answer a question based on it.",
75
+ inputSchema: {
76
+ type: "object",
77
+ properties: {
78
+ url: {
79
+ type: "string",
80
+ description: "The http(s) URL to fetch.",
81
+ },
82
+ question: {
83
+ type: "string",
84
+ description:
85
+ "The question to answer using the fetched URL contents.",
86
+ },
87
+ },
88
+ required: ["url", "question"],
89
+ },
90
+ },
91
+
92
+ /**
93
+ * @param {WebFetchInput} input
94
+ * @returns {Promise<string | Error>}
95
+ */
96
+ impl: async (input) =>
97
+ await noThrow(async () => {
98
+ const validationError = validateInput(input);
99
+ if (validationError) {
100
+ return validationError;
101
+ }
102
+ switch (config.provider) {
103
+ case "gemini":
104
+ case "gemini-vertex-ai":
105
+ return webFetchViaGemini(config, input, 0);
106
+ case "command":
107
+ return webFetchViaCommand(config, input);
108
+ }
109
+ }),
110
+
111
+ /**
112
+ * Reduce the URL to its origin so that approving one URL on a host
113
+ * effectively approves any path on the same host. Pairs with the
114
+ * in-session matcher applying the mask to both sides.
115
+ *
116
+ * @param {Record<string, unknown>} input
117
+ * @returns {Record<string, unknown>}
118
+ */
119
+ maskApprovalInput: (input) => {
120
+ const webFetchInput = /** @type {Partial<WebFetchInput>} */ (input);
121
+ const origin = extractOrigin(webFetchInput.url);
122
+ return { url: origin };
123
+ },
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Truncate `content` to at most `maxLength` characters, keeping the head.
129
+ * When truncation occurs, a `[truncated: ...]` marker is appended.
130
+ *
131
+ * @param {string} content
132
+ * @param {number} maxLength
133
+ * @returns {{ text: string, truncated: boolean, originalLength: number }}
134
+ */
135
+ export function truncateText(content, maxLength) {
136
+ if (content.length <= maxLength) {
137
+ return { text: content, truncated: false, originalLength: content.length };
138
+ }
139
+ const head = content.slice(0, maxLength);
140
+ const truncatedLength = content.length - maxLength;
141
+ return {
142
+ text: `${head}\n\n[truncated: ${truncatedLength} of ${content.length} chars omitted]`,
143
+ truncated: true,
144
+ originalLength: content.length,
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Return the URL's origin (`<scheme>//<host>`) when parseable, otherwise an
150
+ * empty string. Used so per-domain auto-approval works regardless of path.
151
+ *
152
+ * @param {unknown} url
153
+ * @returns {string}
154
+ */
155
+ export function extractOrigin(url) {
156
+ if (typeof url !== "string") {
157
+ return "";
158
+ }
159
+ try {
160
+ const u = new URL(url);
161
+ if (u.protocol !== "http:" && u.protocol !== "https:") {
162
+ return "";
163
+ }
164
+ return `${u.protocol}//${u.host}`;
165
+ } catch {
166
+ return "";
167
+ }
168
+ }
169
+
170
+ /**
171
+ * @param {WebFetchInput} input
172
+ * @returns {Error | null}
173
+ */
174
+ function validateInput(input) {
175
+ if (!input.url || typeof input.url !== "string") {
176
+ return new Error("`url` is required and must be a string.");
177
+ }
178
+ if (!/^https?:\/\//.test(input.url)) {
179
+ return new Error(
180
+ `Invalid URL: \`${input.url}\` must start with http(s)://`,
181
+ );
182
+ }
183
+ if (!input.question || typeof input.question !== "string") {
184
+ return new Error("`question` is required and must be a string.");
185
+ }
186
+ return null;
187
+ }
188
+
189
+ /**
190
+ * @param {WebFetchToolCommandOptions} config
191
+ * @param {WebFetchInput} input
192
+ * @returns {Promise<string | Error>}
193
+ */
194
+ async function webFetchViaCommand(config, input) {
195
+ const maxLength = config.maxLength ?? DEFAULT_MAX_LENGTH;
196
+
197
+ /** @type {string} */
198
+ let raw;
199
+ try {
200
+ raw = await runFetchCommand(config, input.url);
201
+ } catch (err) {
202
+ const message = err instanceof Error ? err.message : String(err);
203
+ console.error(styleText("yellow", message));
204
+ return new Error(message);
205
+ }
206
+
207
+ const { text, truncated, originalLength } = truncateText(raw, maxLength);
208
+
209
+ const fetchCommandDisplay = [config.command, ...config.args, "<URL>"].join(
210
+ " ",
211
+ );
212
+ const systemPrompt = [
213
+ "You answer the user's question based solely on the provided URL contents.",
214
+ `The content is wrapped in a <url href="..."> tag and was fetched with \`${fetchCommandDisplay}\`.`,
215
+ 'If the page is marked truncated="true", treat it as partial.',
216
+ "Cite the source URL inline (e.g., [1]) and list it at the end.",
217
+ "If the contents do not answer the question, say so explicitly rather than guessing.",
218
+ ].join(" ");
219
+
220
+ const attrs = truncated
221
+ ? ` truncated="true" original_length="${originalLength}"`
222
+ : "";
223
+ const userPrompt = [
224
+ `Question: ${input.question}`,
225
+ "",
226
+ "URL content:",
227
+ `<url href="${input.url}"${attrs}>`,
228
+ text,
229
+ "</url>",
230
+ ].join("\n");
231
+
232
+ const userPromptResult = await config.modelCaller({
233
+ messages: [
234
+ {
235
+ role: "system",
236
+ content: [{ type: "text", text: systemPrompt }],
237
+ },
238
+ {
239
+ role: "user",
240
+ content: [{ type: "text", text: userPrompt }],
241
+ },
242
+ ],
243
+ });
244
+
245
+ if (userPromptResult instanceof Error) {
246
+ return userPromptResult;
247
+ }
248
+
249
+ const answerText = userPromptResult.message.content
250
+ .map((part) => (part.type === "text" ? part.text : ""))
251
+ .join("")
252
+ .trim();
253
+
254
+ const suffix = truncated ? " (truncated)" : "";
255
+ const sourcesList = `- [1] ${input.url}${suffix}`;
256
+
257
+ return [answerText, sourcesList].join("\n\n");
258
+ }
259
+
260
+ /**
261
+ * @param {WebFetchToolGeminiOptions | WebFetchToolGeminiVertexAIOptions} config
262
+ * @param {WebFetchInput} input
263
+ * @param {number} retryCount
264
+ * @returns {Promise<string | Error>}
265
+ */
266
+ async function webFetchViaGemini(config, input, retryCount) {
267
+ const model = config.model ?? "gemini-3-flash-preview";
268
+ const url =
269
+ config.provider === "gemini-vertex-ai"
270
+ ? `${config.baseURL}/publishers/google/models/${config.model}:generateContent`
271
+ : config.baseURL
272
+ ? `${config.baseURL}/models/${model}:generateContent`
273
+ : `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`;
274
+
275
+ /** @type {Record<string,string>} */
276
+ const authHeader =
277
+ config.provider === "gemini-vertex-ai"
278
+ ? {
279
+ Authorization: `Bearer ${await getGoogleCloudAccessToken(config.account)}`,
280
+ }
281
+ : {
282
+ "x-goog-api-key": config.apiKey ?? "",
283
+ };
284
+
285
+ const data = {
286
+ contents: [
287
+ {
288
+ role: "user",
289
+ parts: [
290
+ {
291
+ 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.
292
+
293
+ URL: ${input.url}
294
+ Question: ${input.question}`,
295
+ },
296
+ ],
297
+ },
298
+ ],
299
+ tools: [
300
+ {
301
+ url_context: {},
302
+ },
303
+ ],
304
+ };
305
+
306
+ const response = await fetch(url, {
307
+ method: "POST",
308
+ headers: {
309
+ ...authHeader,
310
+ "Content-Type": "application/json",
311
+ },
312
+ body: JSON.stringify(data),
313
+ signal: AbortSignal.timeout(120 * 1000),
314
+ });
315
+
316
+ if (response.status === 429 || response.status >= 500) {
317
+ const interval = Math.min(2 * 2 ** retryCount, 16);
318
+ console.error(
319
+ styleText(
320
+ "yellow",
321
+ `Google API returned ${response.status}. Retrying in ${interval} seconds...`,
322
+ ),
323
+ );
324
+ await new Promise((resolve) => setTimeout(resolve, interval * 1000));
325
+ return webFetchViaGemini(config, input, retryCount + 1);
326
+ }
327
+
328
+ if (!response.ok) {
329
+ return new Error(
330
+ `Failed to fetch URL: status=${response.status}, body=${await response.text()}`,
331
+ );
332
+ }
333
+
334
+ const body = await response.json();
335
+
336
+ const candidate = body.candidates?.[0];
337
+ const text = candidate?.content?.parts?.[0]?.text;
338
+ /** @type {{segment?:{startIndex:number,endIndex:number,text:string},groundingChunkIndices?:number[]}[] | undefined} */
339
+ const supports = candidate?.groundingMetadata?.groundingSupports;
340
+ /** @type {{web?:{uri:string,title:string}}[] | undefined} */
341
+ const chunks = candidate?.groundingMetadata?.groundingChunks;
342
+
343
+ if (typeof text !== "string") {
344
+ return new Error(
345
+ `Unexpected response format from Google: ${JSON.stringify(body)}`,
346
+ );
347
+ }
348
+
349
+ // Sort by end_index desc because Gemini grounding indexes are byte offsets
350
+ // into the original UTF-8 text.
351
+ const sortedSupports = supports?.toSorted(
352
+ (a, b) => (b.segment?.endIndex ?? 0) - (a.segment?.endIndex ?? 0),
353
+ );
354
+
355
+ // Insert citations using UTF-8 byte offsets.
356
+ let textWithCitations = text;
357
+ for (const support of sortedSupports ?? []) {
358
+ const endIndex = support.segment?.endIndex;
359
+ if (
360
+ typeof endIndex !== "number" ||
361
+ !support.groundingChunkIndices?.length
362
+ ) {
363
+ continue;
364
+ }
365
+
366
+ textWithCitations = insertTextAtUtf8ByteIndex(
367
+ textWithCitations,
368
+ endIndex,
369
+ ` [${support.groundingChunkIndices.map((i) => i + 1).join(", ")}] `,
370
+ );
371
+ }
372
+
373
+ const chunkString = (chunks ?? [])
374
+ .map(
375
+ (chunk, index) =>
376
+ `- [${index + 1} - ${chunk.web?.title}](${chunk.web?.uri})`,
377
+ )
378
+ .join("\n");
379
+
380
+ return [textWithCitations, chunkString].join("\n\n");
381
+ }
382
+
383
+ /**
384
+ * Run `command` with `args` followed by `url` and return stdout.
385
+ *
386
+ * The process is spawned directly (no shell). When `command` exits with a
387
+ * non-zero status, the resulting error message includes the URL and any
388
+ * captured stderr to aid diagnosis.
389
+ *
390
+ * @param {WebFetchToolCommandOptions} config
391
+ * @param {string} url
392
+ * @returns {Promise<string>}
393
+ */
394
+ function runFetchCommand(config, url) {
395
+ return new Promise((resolve, reject) => {
396
+ execFile(
397
+ config.command,
398
+ [...config.args, url],
399
+ {
400
+ shell: false,
401
+ env: {
402
+ PATH: process.env.PATH,
403
+ HOME: process.env.HOME,
404
+ LANG: process.env.LANG,
405
+ ...(config.env ?? {}),
406
+ },
407
+ timeout: config.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS,
408
+ maxBuffer: FETCH_MAX_BUFFER_BYTES,
409
+ },
410
+ (err, stdout, stderr) => {
411
+ if (err) {
412
+ reject(
413
+ new Error(
414
+ `${config.command} failed for ${url}: ${err.message}${stderr ? `\n${stderr}` : ""}`,
415
+ ),
416
+ );
417
+ return;
418
+ }
419
+ resolve(stdout);
420
+ },
421
+ );
422
+ });
423
+ }
424
+
425
+ /**
426
+ * @param {string} source
427
+ * @param {number} byteIndex
428
+ * @param {string} insertText
429
+ */
430
+ function insertTextAtUtf8ByteIndex(source, byteIndex, insertText) {
431
+ const sourceBuffer = Buffer.from(source, "utf8");
432
+ const normalizedByteIndex = Math.max(
433
+ 0,
434
+ Math.min(byteIndex, sourceBuffer.length),
435
+ );
436
+
437
+ return Buffer.concat([
438
+ sourceBuffer.subarray(0, normalizedByteIndex),
439
+ Buffer.from(insertText, "utf8"),
440
+ sourceBuffer.subarray(normalizedByteIndex),
441
+ ]).toString("utf8");
442
+ }