@mono-agent/agent-runtime 0.15.3 → 0.16.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/MIGRATION.md +41 -13
- package/README.md +43 -6
- package/package.json +7 -3
- package/src/agent/tools/agent-tool.js +894 -0
- package/src/agent/tools/bash.js +241 -123
- package/src/agent/tools/exec.js +238 -0
- package/src/agent/tools/index.js +10 -3
- package/src/agent/tools/node-repl.js +231 -95
- package/src/agent/tools/pi-bridge.js +115 -24
- package/src/agent/tools/shared/process-runner.js +162 -0
- package/src/agent/tools/shared/semaphore.js +73 -0
- package/src/agent/tools/web-browser-render.js +221 -0
- package/src/agent/tools/web-controller.js +160 -0
- package/src/agent/tools/web-fetch.js +653 -68
- package/src/agent/tools/web-search.js +568 -16
- package/src/ai/pi-interop.js +7 -5
- package/src/ai/pi-oauth-compat.js +193 -0
- package/src/ai/providers/pi-native/stream-subscriber.js +37 -0
- package/src/ai/providers/pi-native/turn-runner.js +73 -8
- package/src/ai/providers/pi-native.js +67 -7
- package/src/ai/runtime/router.js +310 -166
- package/src/ai/types.js +54 -2
- package/src/pi-auth.js +2 -2
- package/src/runtime.js +58 -1
- package/types/agent/tools/agent-tool.d.ts +80 -0
- package/types/agent/tools/bash.d.ts +55 -7
- package/types/agent/tools/exec.d.ts +53 -0
- package/types/agent/tools/index.d.ts +5 -3
- package/types/agent/tools/node-repl.d.ts +28 -3
- package/types/agent/tools/pi-bridge.d.ts +6 -2
- package/types/agent/tools/shared/process-runner.d.ts +33 -0
- package/types/agent/tools/shared/semaphore.d.ts +29 -0
- package/types/agent/tools/web-browser-render.d.ts +16 -0
- package/types/agent/tools/web-controller.d.ts +20 -0
- package/types/agent/tools/web-fetch.d.ts +74 -5
- package/types/agent/tools/web-search.d.ts +81 -5
- package/types/ai/pi-oauth-compat.d.ts +57 -0
- package/types/ai/providers/pi-native/turn-runner.d.ts +33 -2
- package/types/ai/providers/pi-native.d.ts +12 -0
- package/types/ai/runtime/router.d.ts +23 -3
- package/types/ai/types.d.ts +174 -4
- package/types/ai/backend.d.ts +0 -57
- package/types/ai/registry.d.ts +0 -1
|
@@ -1,106 +1,691 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import { Readability } from "@mozilla/readability";
|
|
4
|
+
import { Defuddle as parseDefuddle } from "defuddle/node";
|
|
5
|
+
import { DOMParser, parseHTML } from "linkedom";
|
|
6
|
+
import { extractText as extractPdfText, getDocumentProxy } from "unpdf";
|
|
7
|
+
import { passthroughSandbox } from "../sandbox-seam.js";
|
|
1
8
|
import { DEFAULT_MAX_TOOL_OUTPUT_CHARS } from "./shared/constants.js";
|
|
2
9
|
import { capChars } from "./shared/output-truncation.js";
|
|
3
|
-
import { passthroughSandbox } from "../sandbox-seam.js";
|
|
4
10
|
import { readToolRuntime } from "./shared/runtime-context.js";
|
|
5
11
|
import { resolveSandboxPolicy } from "./shared/tool-context.js";
|
|
12
|
+
import { renderWithAgentBrowser } from "./web-browser-render.js";
|
|
6
13
|
|
|
7
|
-
const FETCH_TIMEOUT_MS =
|
|
14
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
8
15
|
const MAX_REDIRECTS = 5;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const
|
|
16
|
+
const MAX_DECODED_BYTES = 20 * 1024 * 1024;
|
|
17
|
+
const MAX_RETRY_AFTER_MS = 5_000;
|
|
18
|
+
const DEFAULT_FETCH_RETRY_DELAYS_MS = [1_000, 2_000];
|
|
19
|
+
const ALLOWED_REQUEST_HEADERS = new Set([
|
|
20
|
+
"accept",
|
|
21
|
+
"accept-language",
|
|
22
|
+
"range",
|
|
23
|
+
"user-agent",
|
|
24
|
+
]);
|
|
25
|
+
const TRANSIENT_STATUS = new Set([408, 425, 429]);
|
|
13
26
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
27
|
+
class WebFetchError extends Error {
|
|
28
|
+
/**
|
|
29
|
+
* @param {string} code
|
|
30
|
+
* @param {string} message
|
|
31
|
+
* @param {{retryable?: boolean, statusCode?: number}} [options]
|
|
32
|
+
*/
|
|
33
|
+
constructor(code, message, { retryable = false, statusCode } = {}) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "WebFetchError";
|
|
36
|
+
this.code = code;
|
|
37
|
+
this.retryable = retryable;
|
|
38
|
+
this.statusCode = statusCode;
|
|
39
|
+
}
|
|
19
40
|
}
|
|
20
41
|
|
|
21
|
-
|
|
22
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Compatibility wrapper for direct callers.
|
|
44
|
+
*
|
|
45
|
+
* @param {{url: string, headers?: Record<string, string>, max_output_chars?: number, format?: string, render?: string}} params
|
|
46
|
+
* @param {{sandboxPolicy?: any, sandboxEngine?: any, ctx?: any, signal?: AbortSignal, retryDelaysMs?: number[], fetchConfig?: any, fetchImpl?: typeof fetch, browserRenderer?: typeof renderWithAgentBrowser, namespace?: string, registerCleanup?: (cleanup: () => Promise<void>) => () => void}} [options]
|
|
47
|
+
*/
|
|
48
|
+
export async function webFetchToolImpl(params, options = {}) {
|
|
49
|
+
return (await performWebFetch(params, options)).text;
|
|
23
50
|
}
|
|
24
51
|
|
|
25
52
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
53
|
+
* Fetch and locally extract one public URL.
|
|
54
|
+
*
|
|
55
|
+
* @param {{url: string, headers?: Record<string, string>, max_output_chars?: number, format?: string, render?: string}} params
|
|
56
|
+
* @param {{sandboxPolicy?: any, sandboxEngine?: any, ctx?: any, signal?: AbortSignal, retryDelaysMs?: number[], fetchConfig?: any, fetchImpl?: typeof fetch, browserRenderer?: typeof renderWithAgentBrowser, namespace?: string, registerCleanup?: (cleanup: () => Promise<void>) => () => void}} [options]
|
|
28
57
|
*/
|
|
29
|
-
export async function
|
|
30
|
-
{
|
|
31
|
-
|
|
58
|
+
export async function performWebFetch(
|
|
59
|
+
{
|
|
60
|
+
url,
|
|
61
|
+
headers = {},
|
|
62
|
+
max_output_chars,
|
|
63
|
+
format = "markdown",
|
|
64
|
+
render,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
sandboxPolicy,
|
|
68
|
+
sandboxEngine,
|
|
69
|
+
ctx,
|
|
70
|
+
signal,
|
|
71
|
+
retryDelaysMs = DEFAULT_FETCH_RETRY_DELAYS_MS,
|
|
72
|
+
fetchConfig,
|
|
73
|
+
fetchImpl = globalThis.fetch,
|
|
74
|
+
browserRenderer = renderWithAgentBrowser,
|
|
75
|
+
namespace,
|
|
76
|
+
registerCleanup,
|
|
77
|
+
} = {},
|
|
32
78
|
) {
|
|
33
|
-
const
|
|
79
|
+
const startedAt = Date.now();
|
|
34
80
|
let parsed;
|
|
35
|
-
try { parsed = new URL(url); } catch {
|
|
81
|
+
try { parsed = new URL(url); } catch {
|
|
82
|
+
return failure("Error: Invalid URL", "invalid_url", startedAt);
|
|
83
|
+
}
|
|
36
84
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
37
|
-
return "Error: WebFetch only supports http(s) URLs.";
|
|
85
|
+
return failure("Error: WebFetch only supports http(s) URLs.", "unsupported_protocol", startedAt);
|
|
86
|
+
}
|
|
87
|
+
if (parsed.username || parsed.password) {
|
|
88
|
+
return failure("Error: WebFetch URL credentials are not allowed.", "url_credentials_rejected", startedAt);
|
|
38
89
|
}
|
|
90
|
+
const requestHeaders = normalizeRequestHeaders(headers);
|
|
91
|
+
if (requestHeaders.error) {
|
|
92
|
+
return failure(`Error: ${requestHeaders.error}`, "header_rejected", startedAt);
|
|
93
|
+
}
|
|
94
|
+
const outputFormat = ["markdown", "text", "raw"].includes(format) ? format : null;
|
|
95
|
+
if (!outputFormat) {
|
|
96
|
+
return failure("Error: WebFetch format must be markdown, text, or raw.", "invalid_format", startedAt);
|
|
97
|
+
}
|
|
98
|
+
const fetchSettings = normalizeFetchConfig(fetchConfig);
|
|
99
|
+
if (fetchSettings.error) {
|
|
100
|
+
return failure(`Error: ${fetchSettings.error}`, "invalid_fetch_config", startedAt);
|
|
101
|
+
}
|
|
102
|
+
if (render !== undefined && !["never", "auto", "always"].includes(render)) {
|
|
103
|
+
return failure("Error: WebFetch render must be never, auto, or always.", "invalid_render_mode", startedAt);
|
|
104
|
+
}
|
|
105
|
+
// Config is the capability ceiling: the default `never` must make browser
|
|
106
|
+
// rendering impossible, even when untrusted model input asks for `always`.
|
|
107
|
+
const requestedRender = fetchSettings.render === "never"
|
|
108
|
+
? "never"
|
|
109
|
+
: (render ?? "auto");
|
|
110
|
+
if (outputFormat === "raw" && requestedRender !== "never") {
|
|
111
|
+
return failure("Error: WebFetch format raw requires render=never.", "invalid_render_format", startedAt);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const maxChars = positiveInteger(max_output_chars, DEFAULT_MAX_TOOL_OUTPUT_CHARS);
|
|
39
115
|
const resolvedCtx = ctx ?? readToolRuntime();
|
|
40
116
|
const sandbox = resolvedCtx.sandbox ?? passthroughSandbox;
|
|
41
117
|
const policy = resolveSandboxPolicy(resolvedCtx, sandboxPolicy);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
for (let attempt = 0; attempt <=
|
|
118
|
+
const delays = Array.isArray(retryDelaysMs)
|
|
119
|
+
? retryDelaysMs.slice(0, 2).map((value) => Math.max(0, Number(value) || 0))
|
|
120
|
+
: [];
|
|
121
|
+
let attempts = 0;
|
|
122
|
+
let response;
|
|
123
|
+
let finalUrl = parsed.href;
|
|
124
|
+
let redirectCount = 0;
|
|
125
|
+
let responseBytes = 0;
|
|
126
|
+
let bytes;
|
|
127
|
+
|
|
128
|
+
for (let attempt = 0; attempt <= delays.length; attempt += 1) {
|
|
129
|
+
attempts += 1;
|
|
53
130
|
try {
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
131
|
+
const fetched = await fetchFollowingRedirects(parsed, {
|
|
132
|
+
headers: requestHeaders.headers,
|
|
133
|
+
sandbox,
|
|
134
|
+
policy,
|
|
135
|
+
signal,
|
|
136
|
+
fetchImpl,
|
|
137
|
+
});
|
|
138
|
+
response = fetched.response;
|
|
139
|
+
finalUrl = fetched.url;
|
|
140
|
+
redirectCount = fetched.redirects;
|
|
141
|
+
if (isTransientResponse(response) && attempt < delays.length) {
|
|
142
|
+
const delay = retryDelayForResponse(response, delays[attempt]);
|
|
143
|
+
try { await response.body?.cancel(); } catch { /* best effort */ }
|
|
144
|
+
await waitForRetry(delay, signal);
|
|
63
145
|
continue;
|
|
64
146
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
} catch (
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
147
|
+
bytes = await readResponseBytes(response);
|
|
148
|
+
responseBytes = bytes.byteLength;
|
|
149
|
+
break;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (signal?.aborted) {
|
|
152
|
+
return failure("Error fetching URL: request aborted", "aborted", startedAt, {
|
|
153
|
+
attempts,
|
|
154
|
+
retryable: false,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
const normalized = normalizeFetchError(error);
|
|
158
|
+
if (normalized.retryable && attempt < delays.length) {
|
|
159
|
+
response = undefined;
|
|
160
|
+
bytes = undefined;
|
|
161
|
+
try {
|
|
162
|
+
await waitForRetry(delays[attempt], signal);
|
|
163
|
+
} catch (retryError) {
|
|
164
|
+
const retryFailure = normalizeFetchError(retryError);
|
|
165
|
+
return failure(`Error fetching URL: ${retryFailure.message}`, retryFailure.code, startedAt, {
|
|
166
|
+
attempts,
|
|
167
|
+
retryable: false,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
72
170
|
continue;
|
|
73
171
|
}
|
|
74
|
-
|
|
172
|
+
const errorText = normalized.code === "network_denied"
|
|
173
|
+
? `Error: ${normalized.message}`
|
|
174
|
+
: `Error fetching URL: ${normalized.message}`;
|
|
175
|
+
return failure(errorText, normalized.code, startedAt, {
|
|
176
|
+
attempts,
|
|
177
|
+
retryable: normalized.retryable,
|
|
178
|
+
statusCode: normalized.statusCode ?? response?.status,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!response || bytes === undefined) {
|
|
184
|
+
return failure("Error fetching URL: request failed", "request_failed", startedAt, { attempts });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const contentType = response.headers.get("content-type") || "";
|
|
188
|
+
const responseKind = contentKind(contentType, bytes);
|
|
189
|
+
if (!response.ok) {
|
|
190
|
+
const preview = responseKind === "binary"
|
|
191
|
+
? "(binary response body omitted)"
|
|
192
|
+
: decodeBytes(bytes, contentType).slice(0, 500);
|
|
193
|
+
const errorText = [
|
|
194
|
+
`HTTP ${response.status}`,
|
|
195
|
+
`[BEGIN UNTRUSTED WEB ERROR BODY source=${JSON.stringify(finalUrl)}]`,
|
|
196
|
+
preview,
|
|
197
|
+
"[END UNTRUSTED WEB ERROR BODY]",
|
|
198
|
+
].join("\n");
|
|
199
|
+
return failure(errorText, `http_${response.status}`, startedAt, {
|
|
200
|
+
attempts,
|
|
201
|
+
retryable: response.status === 429 || response.status >= 500,
|
|
202
|
+
statusCode: response.status,
|
|
203
|
+
bytes: responseBytes,
|
|
204
|
+
backend: "http",
|
|
205
|
+
redirectCount,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
if (responseKind === "binary") {
|
|
209
|
+
return failure("Error: WebFetch does not return unsupported binary content.", "unsupported_content_type", startedAt, {
|
|
210
|
+
attempts,
|
|
211
|
+
statusCode: response.status,
|
|
212
|
+
bytes: responseBytes,
|
|
213
|
+
backend: "http",
|
|
214
|
+
redirectCount,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
let extracted;
|
|
219
|
+
try {
|
|
220
|
+
extracted = await extractResponse(bytes, {
|
|
221
|
+
contentType,
|
|
222
|
+
format: outputFormat,
|
|
223
|
+
url: finalUrl,
|
|
224
|
+
});
|
|
225
|
+
} catch (error) {
|
|
226
|
+
return failure(`Error extracting URL: ${error?.message || String(error)}`, "extraction_failed", startedAt, {
|
|
227
|
+
attempts,
|
|
228
|
+
statusCode: response.status,
|
|
229
|
+
bytes: responseBytes,
|
|
230
|
+
backend: "http",
|
|
231
|
+
redirectCount,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const shouldRender = responseKind === "html"
|
|
236
|
+
&& (
|
|
237
|
+
requestedRender === "always"
|
|
238
|
+
|| (requestedRender === "auto" && shouldAutoRender(extracted.readableText, decodeBytes(bytes, contentType)))
|
|
239
|
+
);
|
|
240
|
+
let backend = "http";
|
|
241
|
+
let renderFailed = false;
|
|
242
|
+
if (shouldRender) {
|
|
243
|
+
try {
|
|
244
|
+
const rendered = await browserRenderer(finalUrl, {
|
|
245
|
+
browserCommand: fetchSettings.browserCommand,
|
|
246
|
+
namespace,
|
|
247
|
+
sandboxPolicy,
|
|
248
|
+
sandboxEngine,
|
|
249
|
+
ctx: resolvedCtx,
|
|
250
|
+
signal,
|
|
251
|
+
registerCleanup,
|
|
252
|
+
});
|
|
253
|
+
extracted = {
|
|
254
|
+
body: outputFormat === "text" ? markdownToText(rendered) : rendered,
|
|
255
|
+
readableText: markdownToText(rendered),
|
|
256
|
+
title: extracted.title,
|
|
257
|
+
};
|
|
258
|
+
backend = "agent-browser";
|
|
259
|
+
} catch (error) {
|
|
260
|
+
if (requestedRender === "always") {
|
|
261
|
+
return failure(`Error rendering URL: ${error?.message || String(error)}`, "browser_render_failed", startedAt, {
|
|
262
|
+
attempts,
|
|
263
|
+
statusCode: response.status,
|
|
264
|
+
bytes: responseBytes,
|
|
265
|
+
backend: "agent-browser",
|
|
266
|
+
redirectCount,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
renderFailed = true;
|
|
75
270
|
}
|
|
76
271
|
}
|
|
77
|
-
|
|
272
|
+
|
|
273
|
+
const body = extracted.body || "(no readable content)";
|
|
274
|
+
const capped = capChars(body, { label: "WebFetch", maxChars, ctx: resolvedCtx });
|
|
275
|
+
const text = [
|
|
276
|
+
`[BEGIN UNTRUSTED WEB CONTENT source=${JSON.stringify(finalUrl)}]`,
|
|
277
|
+
capped,
|
|
278
|
+
"[END UNTRUSTED WEB CONTENT]",
|
|
279
|
+
].join("\n");
|
|
280
|
+
return {
|
|
281
|
+
text,
|
|
282
|
+
outcome: {
|
|
283
|
+
status: "ok",
|
|
284
|
+
code: renderFailed ? "ok_static_render_failed" : "ok",
|
|
285
|
+
retryable: false,
|
|
286
|
+
attempts,
|
|
287
|
+
backend,
|
|
288
|
+
cacheHit: false,
|
|
289
|
+
durationMs: Date.now() - startedAt,
|
|
290
|
+
bytes: responseBytes,
|
|
291
|
+
truncated: body.length > maxChars,
|
|
292
|
+
statusCode: response.status,
|
|
293
|
+
redirectCount,
|
|
294
|
+
rendered: backend === "agent-browser",
|
|
295
|
+
renderFailed,
|
|
296
|
+
contentKind: responseKind,
|
|
297
|
+
},
|
|
298
|
+
error: false,
|
|
299
|
+
};
|
|
78
300
|
}
|
|
79
301
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
302
|
+
async function fetchFollowingRedirects(initialUrl, options) {
|
|
303
|
+
let current = new URL(initialUrl.href);
|
|
304
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
|
|
305
|
+
if (!options.sandbox.networkAllowsUrl(options.policy, current.href)) {
|
|
306
|
+
throw new WebFetchError(
|
|
307
|
+
hop === 0 ? "network_denied" : "redirect_network_denied",
|
|
308
|
+
hop === 0
|
|
309
|
+
? "Network access denied by sandbox policy."
|
|
310
|
+
: "Network access denied by sandbox policy (redirect).",
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
const response = await options.fetchImpl(current, {
|
|
314
|
+
headers: options.headers,
|
|
89
315
|
redirect: "manual",
|
|
90
|
-
signal:
|
|
316
|
+
signal: requestSignal(options.signal),
|
|
91
317
|
});
|
|
92
|
-
const location =
|
|
93
|
-
if (
|
|
318
|
+
const location = response.headers.get("location");
|
|
319
|
+
if (response.status < 300 || response.status >= 400 || !location) {
|
|
320
|
+
return { response, url: current.href, redirects: hop };
|
|
321
|
+
}
|
|
322
|
+
if (hop === MAX_REDIRECTS) {
|
|
323
|
+
try { await response.body?.cancel(); } catch { /* best effort */ }
|
|
324
|
+
throw new WebFetchError("too_many_redirects", "Too many redirects.");
|
|
325
|
+
}
|
|
94
326
|
let next;
|
|
95
|
-
try { next = new URL(location, current); } catch {
|
|
327
|
+
try { next = new URL(location, current); } catch {
|
|
328
|
+
throw new WebFetchError("invalid_redirect", "Invalid redirect URL.");
|
|
329
|
+
}
|
|
96
330
|
if (next.protocol !== "http:" && next.protocol !== "https:") {
|
|
97
|
-
|
|
331
|
+
throw new WebFetchError("unsupported_redirect_protocol", "WebFetch only supports http(s) URLs.");
|
|
98
332
|
}
|
|
99
|
-
if (
|
|
100
|
-
|
|
333
|
+
if (next.username || next.password) {
|
|
334
|
+
throw new WebFetchError("redirect_credentials_rejected", "Redirect URL credentials are not allowed.");
|
|
101
335
|
}
|
|
102
|
-
try { await
|
|
336
|
+
try { await response.body?.cancel(); } catch { /* best effort */ }
|
|
103
337
|
current = next;
|
|
104
338
|
}
|
|
105
|
-
|
|
339
|
+
throw new WebFetchError("too_many_redirects", "Too many redirects.");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function readResponseBytes(response) {
|
|
343
|
+
const declaredLength = Number(response.headers.get("content-length"));
|
|
344
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_DECODED_BYTES) {
|
|
345
|
+
try { await response.body?.cancel(); } catch { /* best effort */ }
|
|
346
|
+
throw new WebFetchError("response_too_large", `response exceeded ${MAX_DECODED_BYTES} bytes`);
|
|
347
|
+
}
|
|
348
|
+
const reader = response.body?.getReader?.();
|
|
349
|
+
if (!reader) {
|
|
350
|
+
const array = new Uint8Array(await response.arrayBuffer());
|
|
351
|
+
if (array.byteLength > MAX_DECODED_BYTES) {
|
|
352
|
+
throw new WebFetchError("response_too_large", `response exceeded ${MAX_DECODED_BYTES} bytes`);
|
|
353
|
+
}
|
|
354
|
+
return array;
|
|
355
|
+
}
|
|
356
|
+
const chunks = [];
|
|
357
|
+
let bytes = 0;
|
|
358
|
+
while (true) {
|
|
359
|
+
const next = await reader.read();
|
|
360
|
+
if (next.done) break;
|
|
361
|
+
bytes += next.value.byteLength;
|
|
362
|
+
if (bytes > MAX_DECODED_BYTES) {
|
|
363
|
+
try { await reader.cancel(); } catch { /* best effort */ }
|
|
364
|
+
throw new WebFetchError("response_too_large", `response exceeded ${MAX_DECODED_BYTES} bytes`);
|
|
365
|
+
}
|
|
366
|
+
chunks.push(Buffer.from(next.value));
|
|
367
|
+
}
|
|
368
|
+
return new Uint8Array(Buffer.concat(chunks));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function extractResponse(bytes, { contentType, format, url }) {
|
|
372
|
+
const kind = contentKind(contentType, bytes);
|
|
373
|
+
const raw = decodeBytes(bytes, contentType);
|
|
374
|
+
if (format === "raw") return { body: raw, readableText: raw, title: "" };
|
|
375
|
+
if (kind === "pdf") {
|
|
376
|
+
const pdf = await getDocumentProxy(bytes);
|
|
377
|
+
try {
|
|
378
|
+
const extracted = await extractPdfText(pdf, { mergePages: true });
|
|
379
|
+
const body = String(extracted.text || "").trim();
|
|
380
|
+
return {
|
|
381
|
+
body,
|
|
382
|
+
readableText: body,
|
|
383
|
+
title: "",
|
|
384
|
+
};
|
|
385
|
+
} finally {
|
|
386
|
+
try { await /** @type {any} */ (pdf).destroy?.(); } catch { /* best effort */ }
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (kind === "json") {
|
|
390
|
+
let parsed;
|
|
391
|
+
try { parsed = JSON.parse(raw); } catch {
|
|
392
|
+
return { body: raw, readableText: raw, title: "" };
|
|
393
|
+
}
|
|
394
|
+
const pretty = JSON.stringify(parsed, null, 2);
|
|
395
|
+
return {
|
|
396
|
+
body: format === "markdown" ? `\`\`\`json\n${pretty}\n\`\`\`` : pretty,
|
|
397
|
+
readableText: pretty,
|
|
398
|
+
title: "",
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
if (kind === "xml") {
|
|
402
|
+
const body = extractXml(raw, format);
|
|
403
|
+
return { body, readableText: markdownToText(body), title: "" };
|
|
404
|
+
}
|
|
405
|
+
if (kind === "html") {
|
|
406
|
+
return extractHtml(raw, url, format);
|
|
407
|
+
}
|
|
408
|
+
return { body: raw.trim(), readableText: raw.trim(), title: "" };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function extractHtml(html, url, format) {
|
|
412
|
+
let title = "";
|
|
413
|
+
let markdown = "";
|
|
414
|
+
try {
|
|
415
|
+
const { document } = parseHTML(html);
|
|
416
|
+
const parsed = await parseDefuddle(/** @type {any} */ (document), url, {
|
|
417
|
+
markdown: true,
|
|
418
|
+
separateMarkdown: true,
|
|
419
|
+
useAsync: false,
|
|
420
|
+
});
|
|
421
|
+
title = String(parsed.title || "").trim();
|
|
422
|
+
markdown = String(parsed.contentMarkdown || parsed.content || "").trim();
|
|
423
|
+
} catch { /* Readability fallback below */ }
|
|
424
|
+
|
|
425
|
+
if (meaningfulCharacters(markdown) < 1) {
|
|
426
|
+
try {
|
|
427
|
+
const { document } = parseHTML(html);
|
|
428
|
+
const article = new Readability(/** @type {any} */ (document)).parse();
|
|
429
|
+
if (article) {
|
|
430
|
+
title ||= String(article.title || "").trim();
|
|
431
|
+
markdown = htmlToText(article.content || article.textContent || "");
|
|
432
|
+
}
|
|
433
|
+
} catch { /* final body-text fallback below */ }
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (meaningfulCharacters(markdown) < 1) {
|
|
437
|
+
const { document } = parseHTML(html);
|
|
438
|
+
title ||= collapseWhitespace(document.querySelector("title")?.textContent);
|
|
439
|
+
markdown = collapseDocumentText(document.body?.textContent || "");
|
|
440
|
+
}
|
|
441
|
+
const readableText = markdownToText(markdown);
|
|
442
|
+
if (format === "text") return { body: readableText, readableText, title };
|
|
443
|
+
const body = title && !markdown.trimStart().startsWith(`# ${title}`)
|
|
444
|
+
? `# ${title}\n\n${markdown}`
|
|
445
|
+
: markdown;
|
|
446
|
+
return { body, readableText, title };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function extractXml(xml, format) {
|
|
450
|
+
const document = new DOMParser().parseFromString(String(xml || ""), "text/xml");
|
|
451
|
+
const entries = [...document.querySelectorAll("item, entry")].slice(0, 50);
|
|
452
|
+
if (entries.length === 0) {
|
|
453
|
+
return collapseDocumentText(document.documentElement?.textContent || xml);
|
|
454
|
+
}
|
|
455
|
+
const blocks = entries.map((entry) => {
|
|
456
|
+
const title = collapseWhitespace(entry.querySelector("title")?.textContent) || "Untitled";
|
|
457
|
+
const linkElement = entry.querySelector("link");
|
|
458
|
+
const link = linkElement?.getAttribute("href") || collapseWhitespace(linkElement?.textContent);
|
|
459
|
+
const description = collapseWhitespace(
|
|
460
|
+
entry.querySelector("description, summary, content")?.textContent,
|
|
461
|
+
);
|
|
462
|
+
if (format === "text") return [title, link, description].filter(Boolean).join("\n");
|
|
463
|
+
return [
|
|
464
|
+
`## ${title}`,
|
|
465
|
+
link ? `[${link}](${link})` : "",
|
|
466
|
+
description,
|
|
467
|
+
].filter(Boolean).join("\n\n");
|
|
468
|
+
});
|
|
469
|
+
return blocks.join("\n\n");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function contentKind(contentType, bytes) {
|
|
473
|
+
const mime = String(contentType || "").split(";", 1)[0].trim().toLowerCase();
|
|
474
|
+
if (mime === "application/pdf" || startsWithPdf(bytes)) return "pdf";
|
|
475
|
+
if (mime.includes("json") || mime.endsWith("+json")) return "json";
|
|
476
|
+
if (
|
|
477
|
+
mime.includes("xml")
|
|
478
|
+
|| mime.includes("rss")
|
|
479
|
+
|| mime.includes("atom")
|
|
480
|
+
|| mime.endsWith("+xml")
|
|
481
|
+
) return "xml";
|
|
482
|
+
if (mime.includes("html") || looksLikeHtml(bytes)) return "html";
|
|
483
|
+
if (
|
|
484
|
+
mime.startsWith("text/")
|
|
485
|
+
|| [
|
|
486
|
+
"application/ecmascript",
|
|
487
|
+
"application/graphql",
|
|
488
|
+
"application/javascript",
|
|
489
|
+
"application/rtf",
|
|
490
|
+
"application/sql",
|
|
491
|
+
"application/x-httpd-php",
|
|
492
|
+
"application/x-yaml",
|
|
493
|
+
"application/yaml",
|
|
494
|
+
].includes(mime)
|
|
495
|
+
) return "text";
|
|
496
|
+
if (!mime && !looksBinary(bytes)) return "text";
|
|
497
|
+
return "binary";
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function startsWithPdf(bytes) {
|
|
501
|
+
return Buffer.from(bytes.subarray(0, 5)).toString("ascii") === "%PDF-";
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function looksLikeHtml(bytes) {
|
|
505
|
+
return /^\s*(?:<!doctype html|<html|<head|<body)/iu.test(
|
|
506
|
+
Buffer.from(bytes.subarray(0, 512)).toString("utf8"),
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function looksBinary(bytes) {
|
|
511
|
+
const sample = bytes.subarray(0, Math.min(bytes.byteLength, 1_024));
|
|
512
|
+
if (sample.byteLength === 0) return false;
|
|
513
|
+
let controls = 0;
|
|
514
|
+
for (const byte of sample) {
|
|
515
|
+
if (byte === 0) return true;
|
|
516
|
+
if (byte < 0x20 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0c && byte !== 0x0d) {
|
|
517
|
+
controls += 1;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return controls / sample.byteLength > 0.1;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function decodeBytes(bytes, contentType) {
|
|
524
|
+
const match = String(contentType || "").match(/charset\s*=\s*["']?([^;"'\s]+)/iu);
|
|
525
|
+
const charset = match?.[1] || "utf-8";
|
|
526
|
+
try {
|
|
527
|
+
return new TextDecoder(charset, { fatal: false }).decode(bytes);
|
|
528
|
+
} catch {
|
|
529
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function normalizeRequestHeaders(headers) {
|
|
534
|
+
if (headers === null || typeof headers !== "object" || Array.isArray(headers)) {
|
|
535
|
+
return { error: "WebFetch headers must be an object." };
|
|
536
|
+
}
|
|
537
|
+
const normalized = {
|
|
538
|
+
Accept: "text/markdown,text/html,application/xhtml+xml,application/json,application/pdf,text/plain;q=0.9,*/*;q=0.5",
|
|
539
|
+
"User-Agent": "mono-agent-web/1",
|
|
540
|
+
};
|
|
541
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
542
|
+
const lower = name.trim().toLowerCase();
|
|
543
|
+
if (!ALLOWED_REQUEST_HEADERS.has(lower)) {
|
|
544
|
+
return { error: `WebFetch header is not allowed: ${name}` };
|
|
545
|
+
}
|
|
546
|
+
if (typeof value !== "string" || /[\r\n]/u.test(value)) {
|
|
547
|
+
return { error: `WebFetch header value is invalid: ${name}` };
|
|
548
|
+
}
|
|
549
|
+
const canonical = lower.split("-").map((part) => part[0]?.toUpperCase() + part.slice(1)).join("-");
|
|
550
|
+
normalized[canonical] = value;
|
|
551
|
+
}
|
|
552
|
+
return { headers: normalized };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function normalizeFetchConfig(input) {
|
|
556
|
+
const render = input?.render ?? "never";
|
|
557
|
+
if (!["never", "auto"].includes(render)) {
|
|
558
|
+
return { error: "Configured web fetch render mode must be never or auto." };
|
|
559
|
+
}
|
|
560
|
+
const browserCommand = input?.browserCommand ?? "agent-browser";
|
|
561
|
+
if (
|
|
562
|
+
typeof browserCommand !== "string"
|
|
563
|
+
|| browserCommand.trim().length === 0
|
|
564
|
+
|| /[\u0000-\u001f\u007f]/u.test(browserCommand)
|
|
565
|
+
) {
|
|
566
|
+
return { error: "Web browser command must be a direct executable name or path." };
|
|
567
|
+
}
|
|
568
|
+
return { render, browserCommand: browserCommand.trim() };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function shouldAutoRender(readableText, html) {
|
|
572
|
+
if (meaningfulCharacters(readableText) >= 200) return false;
|
|
573
|
+
const scriptCount = (html.match(/<script\b/giu) || []).length;
|
|
574
|
+
const hasAppRoot = /<(?:div|main)[^>]+(?:id|class)=["'][^"']*(?:app|root|__next|nuxt|svelte)[^"']*["']/iu.test(html);
|
|
575
|
+
const hasSpaAssets = /\b(?:webpack|__NEXT_DATA__|vite|hydration|data-reactroot)\b/iu.test(html);
|
|
576
|
+
return scriptCount >= 2 && (hasAppRoot || hasSpaAssets);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function meaningfulCharacters(value) {
|
|
580
|
+
return markdownToText(value).replace(/\s/gu, "").length;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function htmlToText(value) {
|
|
584
|
+
try {
|
|
585
|
+
const { document } = parseHTML(String(value || ""));
|
|
586
|
+
return collapseDocumentText(document.body?.textContent || document.documentElement?.textContent || "");
|
|
587
|
+
} catch {
|
|
588
|
+
return collapseDocumentText(String(value || "").replace(/<[^>]+>/gu, " "));
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function markdownToText(value) {
|
|
593
|
+
return collapseDocumentText(
|
|
594
|
+
String(value || "")
|
|
595
|
+
.replace(/```[\s\S]*?```/gu, (block) => block.replace(/^```[^\n]*\n?|```$/gu, ""))
|
|
596
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/gu, "$1")
|
|
597
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/gu, "$1")
|
|
598
|
+
.replace(/^[#>*+-]+\s*/gmu, "")
|
|
599
|
+
.replace(/[*_`~]/gu, ""),
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function collapseDocumentText(value) {
|
|
604
|
+
return String(value || "")
|
|
605
|
+
.replace(/\r/gu, "")
|
|
606
|
+
.replace(/[ \t]+\n/gu, "\n")
|
|
607
|
+
.replace(/\n{3,}/gu, "\n\n")
|
|
608
|
+
.replace(/[ \t]{2,}/gu, " ")
|
|
609
|
+
.trim();
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function collapseWhitespace(value) {
|
|
613
|
+
return String(value || "").replace(/\s+/gu, " ").trim();
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function requestSignal(signal) {
|
|
617
|
+
const timeout = AbortSignal.timeout(FETCH_TIMEOUT_MS);
|
|
618
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function isTransientResponse(response) {
|
|
622
|
+
return TRANSIENT_STATUS.has(response.status) || response.status >= 500;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function retryDelayForResponse(response, fallback) {
|
|
626
|
+
const value = response.headers.get("retry-after");
|
|
627
|
+
if (!value) return fallback;
|
|
628
|
+
const seconds = Number(value);
|
|
629
|
+
if (Number.isFinite(seconds)) return Math.min(MAX_RETRY_AFTER_MS, Math.max(0, seconds * 1_000));
|
|
630
|
+
const date = Date.parse(value);
|
|
631
|
+
if (!Number.isFinite(date)) return fallback;
|
|
632
|
+
return Math.min(MAX_RETRY_AFTER_MS, Math.max(0, date - Date.now()));
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function waitForRetry(ms, signal) {
|
|
636
|
+
if (!ms) return Promise.resolve();
|
|
637
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
638
|
+
const finish = () => {
|
|
639
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
640
|
+
resolvePromise();
|
|
641
|
+
};
|
|
642
|
+
const timer = setTimeout(finish, ms);
|
|
643
|
+
timer.unref?.();
|
|
644
|
+
const onAbort = () => {
|
|
645
|
+
clearTimeout(timer);
|
|
646
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
647
|
+
rejectPromise(new WebFetchError("aborted", "request aborted"));
|
|
648
|
+
};
|
|
649
|
+
if (signal?.aborted) onAbort();
|
|
650
|
+
else signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function normalizeFetchError(error) {
|
|
655
|
+
if (error instanceof WebFetchError) return error;
|
|
656
|
+
const code = error?.code ?? error?.cause?.code;
|
|
657
|
+
const timedOut = error?.name === "TimeoutError";
|
|
658
|
+
const aborted = error?.name === "AbortError";
|
|
659
|
+
const transient = timedOut
|
|
660
|
+
|| aborted
|
|
661
|
+
|| ["ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EAI_AGAIN"].includes(code);
|
|
662
|
+
return new WebFetchError(
|
|
663
|
+
timedOut ? "timeout" : (aborted ? "aborted" : (code || "request_failed")),
|
|
664
|
+
error?.message || String(error),
|
|
665
|
+
{ retryable: transient },
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function failure(text, code, startedAt, extra = {}) {
|
|
670
|
+
return {
|
|
671
|
+
text,
|
|
672
|
+
outcome: {
|
|
673
|
+
status: "error",
|
|
674
|
+
code,
|
|
675
|
+
retryable: false,
|
|
676
|
+
attempts: 0,
|
|
677
|
+
backend: "none",
|
|
678
|
+
cacheHit: false,
|
|
679
|
+
durationMs: Date.now() - startedAt,
|
|
680
|
+
bytes: Buffer.byteLength(text, "utf8"),
|
|
681
|
+
truncated: false,
|
|
682
|
+
...extra,
|
|
683
|
+
},
|
|
684
|
+
error: true,
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function positiveInteger(value, fallback) {
|
|
689
|
+
const number = Number(value);
|
|
690
|
+
return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
|
|
106
691
|
}
|