@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.
- package/README.md +19 -9
- package/package.json +1 -1
- package/src/cliCost.mjs +21 -1
- package/src/cliFormatter.mjs +19 -12
- package/src/config.d.ts +64 -4
- package/src/config.mjs +8 -8
- package/src/main.mjs +79 -32
- package/src/tools/webFetch.mjs +442 -0
- package/src/tools/webSearch.mjs +503 -0
- package/src/tools/askURL.mjs +0 -209
- package/src/tools/askWeb.mjs +0 -208
|
@@ -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
|
+
}
|