@mono-agent/agent-runtime 0.15.2 → 0.15.4

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.
Files changed (39) hide show
  1. package/README.md +55 -7
  2. package/package.json +5 -1
  3. package/src/agent/tools/agent-tool.js +859 -0
  4. package/src/agent/tools/bash.js +241 -123
  5. package/src/agent/tools/exec.js +238 -0
  6. package/src/agent/tools/index.js +10 -3
  7. package/src/agent/tools/node-repl.js +231 -95
  8. package/src/agent/tools/pi-bridge.js +115 -24
  9. package/src/agent/tools/shared/process-runner.js +162 -0
  10. package/src/agent/tools/shared/semaphore.js +73 -0
  11. package/src/agent/tools/web-browser-render.js +221 -0
  12. package/src/agent/tools/web-controller.js +160 -0
  13. package/src/agent/tools/web-fetch.js +653 -68
  14. package/src/agent/tools/web-search.js +568 -16
  15. package/src/ai/providers/codex-app.js +18 -0
  16. package/src/ai/providers/pi-native/stream-subscriber.js +37 -0
  17. package/src/ai/providers/pi-native/turn-runner.js +60 -5
  18. package/src/ai/providers/pi-native.js +49 -5
  19. package/src/ai/runtime/router.js +302 -166
  20. package/src/ai/types.js +52 -1
  21. package/src/runtime.js +51 -1
  22. package/types/agent/tools/agent-tool.d.ts +60 -0
  23. package/types/agent/tools/bash.d.ts +55 -7
  24. package/types/agent/tools/exec.d.ts +53 -0
  25. package/types/agent/tools/index.d.ts +5 -3
  26. package/types/agent/tools/node-repl.d.ts +28 -3
  27. package/types/agent/tools/pi-bridge.d.ts +6 -2
  28. package/types/agent/tools/shared/process-runner.d.ts +33 -0
  29. package/types/agent/tools/shared/semaphore.d.ts +29 -0
  30. package/types/agent/tools/web-browser-render.d.ts +16 -0
  31. package/types/agent/tools/web-controller.d.ts +20 -0
  32. package/types/agent/tools/web-fetch.d.ts +74 -5
  33. package/types/agent/tools/web-search.d.ts +81 -5
  34. package/types/ai/backend.d.ts +57 -0
  35. package/types/ai/providers/pi-native/turn-runner.d.ts +34 -2
  36. package/types/ai/providers/pi-native.d.ts +12 -0
  37. package/types/ai/registry.d.ts +1 -0
  38. package/types/ai/runtime/router.d.ts +23 -3
  39. package/types/ai/types.d.ts +163 -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 = 15000;
14
+ const FETCH_TIMEOUT_MS = 15_000;
8
15
  const MAX_REDIRECTS = 5;
9
- // Backoff delays between retry attempts (length = number of retries). Retrying
10
- // transient failures in-tool stops the model from burning whole reasoning rounds
11
- // re-issuing the fetch (or falling back to Bash curl) on a momentary network blip.
12
- const DEFAULT_FETCH_RETRY_DELAYS_MS = [1000, 2000];
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
- function isTransientFetchError(err) {
15
- if (err === undefined || err === null) return false;
16
- if (err.name === "AbortError" || err.name === "TimeoutError") return true; // timeout
17
- const code = err.code ?? err.cause?.code;
18
- return code === "ECONNRESET" || code === "ECONNREFUSED" || code === "ETIMEDOUT" || code === "EAI_AGAIN";
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
- function fetchRetryDelay(ms) {
22
- return new Promise((resolve) => { setTimeout(resolve, ms); });
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
- * @param {{url: string, headers?: Record<string, string>, max_output_chars?: number}} params
27
- * @param {{sandboxPolicy?: any, ctx?: any, retryDelaysMs?: number[]}} [options]
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 webFetchToolImpl(
30
- { url, headers = {}, max_output_chars },
31
- { sandboxPolicy, ctx, retryDelaysMs = DEFAULT_FETCH_RETRY_DELAYS_MS } = {},
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 maxChars = Number(max_output_chars) || DEFAULT_MAX_TOOL_OUTPUT_CHARS;
79
+ const startedAt = Date.now();
34
80
  let parsed;
35
- try { parsed = new URL(url); } catch { return "Error: Invalid URL"; }
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
- if (!sandbox.networkAllowsUrl(policy, parsed.href)) return "Error: Network access denied by sandbox policy.";
43
- const requestHeaders = { "User-Agent": "AgentRuntime/0.1", ...headers };
44
- // `policy.network` can be absent (a hand-built, non-real-mode policy — the
45
- // networkAllowsUrl gate above already denied any real-mode policy missing
46
- // it); treat that as unrestricted rather than dereferencing `.mode` on
47
- // undefined.
48
- const restricted = policy !== undefined && policy.network !== undefined && policy.network.mode !== "all";
49
- const maxRetries = Array.isArray(retryDelaysMs) ? retryDelaysMs.length : 0;
50
-
51
- let lastErrorMessage = "request failed";
52
- for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
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 resp = restricted
55
- ? await fetchCheckingRedirects(parsed, requestHeaders, policy, sandbox)
56
- : await fetch(url, { headers: requestHeaders, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
57
- if (typeof resp === "string") return resp; // policy/redirect error — not retryable
58
- // Transient server errors (5xx) are worth one more attempt.
59
- if (resp.status >= 500 && resp.status < 600 && attempt < maxRetries) {
60
- try { await resp.body?.cancel(); } catch { /* best-effort */ }
61
- lastErrorMessage = `HTTP ${resp.status}`;
62
- await fetchRetryDelay(retryDelaysMs[attempt]);
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
- const text = await resp.text();
66
- if (!resp.ok) return `HTTP ${resp.status}: ${text.slice(0, 500)}`;
67
- return capChars(text, { label: "WebFetch", maxChars, ctx });
68
- } catch (err) {
69
- lastErrorMessage = err.message;
70
- if (isTransientFetchError(err) && attempt < maxRetries) {
71
- await fetchRetryDelay(retryDelaysMs[attempt]);
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
- return `Error fetching URL: ${err.message}`;
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
- return `Error fetching URL: ${lastErrorMessage}`;
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
- // fetch() follows redirects transparently, which would let an allowed host
81
- // bounce the request to a denied one — follow them manually and re-check the
82
- // policy on every hop. Custom headers only travel to the original origin.
83
- async function fetchCheckingRedirects(initialUrl, headers, policy, sandbox) {
84
- let current = initialUrl;
85
- for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
86
- const sameOrigin = current.origin === initialUrl.origin;
87
- const resp = await fetch(current, {
88
- headers: sameOrigin ? headers : { "User-Agent": headers["User-Agent"] },
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: AbortSignal.timeout(FETCH_TIMEOUT_MS),
316
+ signal: requestSignal(options.signal),
91
317
  });
92
- const location = resp.headers.get("location");
93
- if (resp.status < 300 || resp.status >= 400 || !location) return resp;
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 { return "Error: Invalid redirect URL."; }
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
- return "Error: WebFetch only supports http(s) URLs.";
331
+ throw new WebFetchError("unsupported_redirect_protocol", "WebFetch only supports http(s) URLs.");
98
332
  }
99
- if (!sandbox.networkAllowsUrl(policy, next.href)) {
100
- return "Error: Network access denied by sandbox policy (redirect).";
333
+ if (next.username || next.password) {
334
+ throw new WebFetchError("redirect_credentials_rejected", "Redirect URL credentials are not allowed.");
101
335
  }
102
- try { await resp.body?.cancel(); } catch { /* best-effort */ }
336
+ try { await response.body?.cancel(); } catch { /* best effort */ }
103
337
  current = next;
104
338
  }
105
- return "Error: Too many redirects.";
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
  }