@mono-agent/agent-runtime 0.20.11 → 0.21.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.
Files changed (148) hide show
  1. package/ARCHITECTURE.md +50 -11
  2. package/MIGRATION.md +288 -26
  3. package/README.md +352 -477
  4. package/package.json +13 -44
  5. package/src/agent/tool-bloat.js +145 -9
  6. package/src/agent/tools/agent-tool.js +108 -9
  7. package/src/agent/tools/bash.js +11 -26
  8. package/src/agent/tools/codex-subscription-search.js +123 -29
  9. package/src/agent/tools/exec.js +10 -2
  10. package/src/agent/tools/index.js +7 -0
  11. package/src/agent/tools/monitor.js +149 -0
  12. package/src/agent/tools/pi-bridge.js +123 -19
  13. package/src/agent/tools/shared/bash-environment.js +31 -0
  14. package/src/agent/tools/shared/monitors.js +293 -0
  15. package/src/agent/tools/shared/path-resolver.js +25 -6
  16. package/src/agent/tools/shared/process-jobs.js +6 -1
  17. package/src/agent/tools/shared/process-runner.js +26 -6
  18. package/src/agent/tools/shared/tool-context.js +8 -0
  19. package/src/agent/tools/web-access-interstitial.js +70 -0
  20. package/src/agent/tools/web-browser-render.js +83 -58
  21. package/src/agent/tools/web-controller.js +112 -21
  22. package/src/agent/tools/web-document-extractor.js +379 -0
  23. package/src/agent/tools/web-fetch.js +271 -243
  24. package/src/agent/tools/web-request.js +65 -0
  25. package/src/agent/tools/web-search-output.js +165 -0
  26. package/src/agent/tools/web-search-state.js +75 -0
  27. package/src/agent/tools/web-search.js +532 -71
  28. package/src/ai/cost.js +13 -68
  29. package/src/ai/failure.js +3 -3
  30. package/src/ai/index.js +5 -17
  31. package/src/ai/observer.js +8 -0
  32. package/src/ai/pi-interop.js +221 -1
  33. package/src/ai/pi-oauth-compat.js +1 -1
  34. package/src/ai/provider-check.js +131 -0
  35. package/src/ai/providers/codex/app-server-client.js +592 -0
  36. package/src/ai/providers/pi-models.js +18 -10
  37. package/src/ai/providers/pi-native/compaction-driver.js +94 -42
  38. package/src/ai/providers/pi-native/compaction-summary.js +140 -0
  39. package/src/ai/providers/pi-native/harness-adapter.js +376 -0
  40. package/src/ai/providers/pi-native/prompt-cache-diagnostics.js +103 -0
  41. package/src/ai/providers/pi-native/provider-attribution.js +102 -0
  42. package/src/ai/providers/pi-native/result-builder.js +38 -14
  43. package/src/ai/providers/pi-native/session-lifecycle.js +253 -55
  44. package/src/ai/providers/pi-native/stream-subscriber.js +52 -6
  45. package/src/ai/providers/pi-native/terminal-recovery.js +40 -0
  46. package/src/ai/providers/pi-native/turn-runner.js +279 -28
  47. package/src/ai/providers/pi-native.js +206 -61
  48. package/src/ai/runtime/capabilities.js +11 -56
  49. package/src/ai/runtime/live-input-events.js +250 -54
  50. package/src/ai/runtime/model-refs.js +118 -153
  51. package/src/ai/runtime/registry.js +22 -56
  52. package/src/ai/runtime/router.js +76 -417
  53. package/src/ai/runtime/session-liveness.js +3 -4
  54. package/src/ai/runtime/sessions.js +4 -5
  55. package/src/ai/runtime/tool-policy.js +0 -2
  56. package/src/ai/tool-lifecycle.js +32 -18
  57. package/src/ai/types.js +37 -112
  58. package/src/index.js +0 -6
  59. package/src/runtime.js +29 -16
  60. package/types/agent/tool-bloat.d.ts +1 -1
  61. package/types/agent/tools/agent-tool.d.ts +4 -2
  62. package/types/agent/tools/bash.d.ts +5 -3
  63. package/types/agent/tools/codex-subscription-search.d.ts +7 -3
  64. package/types/agent/tools/exec.d.ts +5 -3
  65. package/types/agent/tools/index.d.ts +1 -0
  66. package/types/agent/tools/monitor.d.ts +47 -0
  67. package/types/agent/tools/pi-bridge.d.ts +7 -4
  68. package/types/agent/tools/shared/bash-environment.d.ts +4 -0
  69. package/types/agent/tools/shared/monitors.d.ts +98 -0
  70. package/types/agent/tools/shared/process-jobs.d.ts +5 -1
  71. package/types/agent/tools/shared/process-runner.d.ts +14 -4
  72. package/types/agent/tools/shared/tool-context.d.ts +2 -0
  73. package/types/agent/tools/web-access-interstitial.d.ts +23 -0
  74. package/types/agent/tools/web-browser-render.d.ts +4 -1
  75. package/types/agent/tools/web-controller.d.ts +4 -2
  76. package/types/agent/tools/web-document-extractor.d.ts +27 -0
  77. package/types/agent/tools/web-fetch.d.ts +19 -24
  78. package/types/agent/tools/web-request.d.ts +20 -0
  79. package/types/agent/tools/web-search-output.d.ts +31 -0
  80. package/types/agent/tools/web-search-state.d.ts +21 -0
  81. package/types/agent/tools/web-search.d.ts +10 -45
  82. package/types/ai/cost.d.ts +1 -2
  83. package/types/ai/index.d.ts +2 -4
  84. package/types/ai/observer.d.ts +6 -0
  85. package/types/ai/pi-interop.d.ts +81 -0
  86. package/types/ai/provider-check.d.ts +53 -0
  87. package/types/ai/providers/codex/app-server-client.d.ts +37 -0
  88. package/types/ai/providers/pi-native/compaction-driver.d.ts +2 -1
  89. package/types/ai/providers/pi-native/compaction-summary.d.ts +19 -0
  90. package/types/ai/providers/pi-native/harness-adapter.d.ts +58 -0
  91. package/types/ai/providers/pi-native/prompt-cache-diagnostics.d.ts +3 -0
  92. package/types/ai/providers/pi-native/provider-attribution.d.ts +26 -0
  93. package/types/ai/providers/pi-native/result-builder.d.ts +14 -4
  94. package/types/ai/providers/pi-native/session-lifecycle.d.ts +25 -6
  95. package/types/ai/providers/pi-native/stream-subscriber.d.ts +2 -2
  96. package/types/ai/providers/pi-native/terminal-recovery.d.ts +2 -0
  97. package/types/ai/providers/pi-native/turn-runner.d.ts +68 -10
  98. package/types/ai/providers/pi-native.d.ts +21 -4
  99. package/types/ai/runtime/capabilities.d.ts +21 -70
  100. package/types/ai/runtime/live-input-events.d.ts +32 -8
  101. package/types/ai/runtime/model-refs.d.ts +0 -24
  102. package/types/ai/runtime/router.d.ts +3 -10
  103. package/types/ai/runtime/tool-policy.d.ts +0 -2
  104. package/types/ai/tool-lifecycle.d.ts +4 -3
  105. package/types/ai/types.d.ts +162 -256
  106. package/types/index.d.ts +0 -1
  107. package/src/ai/providers/acp-client.js +0 -1149
  108. package/src/ai/providers/acp-privacy.js +0 -124
  109. package/src/ai/providers/acp-public.js +0 -21
  110. package/src/ai/providers/acp-session-tokens.js +0 -282
  111. package/src/ai/providers/acp-transport.js +0 -356
  112. package/src/ai/providers/acp.js +0 -543
  113. package/src/ai/providers/claude-cli.js +0 -883
  114. package/src/ai/providers/claude-sandbox.js +0 -71
  115. package/src/ai/providers/claude-sdk-discovery-worker.js +0 -53
  116. package/src/ai/providers/claude-sdk-discovery.js +0 -352
  117. package/src/ai/providers/claude-sdk.js +0 -1127
  118. package/src/ai/providers/claude-subagent-activity.js +0 -719
  119. package/src/ai/providers/claude-subagents.js +0 -88
  120. package/src/ai/providers/codex-app.js +0 -2946
  121. package/src/ai/providers/opencode-app.js +0 -1109
  122. package/src/ai/providers/opencode-discovery.js +0 -39
  123. package/src/ai/providers/opencode-server.js +0 -508
  124. package/src/ai/runtime/context-windows.js +0 -46
  125. package/src/ai/runtime/fast-mode.js +0 -8
  126. package/src/ai/streaming/codex-events.js +0 -146
  127. package/src/ai/streaming/opencode-events.js +0 -59
  128. package/types/ai/providers/acp-client.d.ts +0 -227
  129. package/types/ai/providers/acp-privacy.d.ts +0 -25
  130. package/types/ai/providers/acp-public.d.ts +0 -7
  131. package/types/ai/providers/acp-session-tokens.d.ts +0 -41
  132. package/types/ai/providers/acp-transport.d.ts +0 -45
  133. package/types/ai/providers/acp.d.ts +0 -93
  134. package/types/ai/providers/claude-cli.d.ts +0 -305
  135. package/types/ai/providers/claude-sandbox.d.ts +0 -79
  136. package/types/ai/providers/claude-sdk-discovery-worker.d.ts +0 -1
  137. package/types/ai/providers/claude-sdk-discovery.d.ts +0 -97
  138. package/types/ai/providers/claude-sdk.d.ts +0 -138
  139. package/types/ai/providers/claude-subagent-activity.d.ts +0 -53
  140. package/types/ai/providers/claude-subagents.d.ts +0 -18
  141. package/types/ai/providers/codex-app.d.ts +0 -151
  142. package/types/ai/providers/opencode-app.d.ts +0 -96
  143. package/types/ai/providers/opencode-discovery.d.ts +0 -4
  144. package/types/ai/providers/opencode-server.d.ts +0 -20
  145. package/types/ai/runtime/context-windows.d.ts +0 -9
  146. package/types/ai/runtime/fast-mode.d.ts +0 -2
  147. package/types/ai/streaming/codex-events.d.ts +0 -40
  148. package/types/ai/streaming/opencode-events.d.ts +0 -42
@@ -1,15 +1,14 @@
1
+ import { withWebDeadline, coordinatedWebRequest, webRequestFailure } from "./web-request.js";
1
2
  // @ts-check
2
3
 
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
4
  import { passthroughSandbox } from "../sandbox-seam.js";
8
5
  import { DEFAULT_MAX_TOOL_OUTPUT_CHARS } from "./shared/constants.js";
9
6
  import { capChars } from "./shared/output-truncation.js";
10
7
  import { readToolRuntime } from "./shared/runtime-context.js";
11
8
  import { resolveSandboxPolicy } from "./shared/tool-context.js";
12
9
  import { renderWithAgentBrowser } from "./web-browser-render.js";
10
+ import { contentKind, decodeWebBytes, extractWebDocument, markdownToText, shouldAutoRender } from "./web-document-extractor.js";
11
+ import { assertNoWebAccessInterstitial } from "./web-access-interstitial.js";
13
12
 
14
13
  const FETCH_TIMEOUT_MS = 15_000;
15
14
  const MAX_REDIRECTS = 5;
@@ -28,22 +27,23 @@ class WebFetchError extends Error {
28
27
  /**
29
28
  * @param {string} code
30
29
  * @param {string} message
31
- * @param {{retryable?: boolean, statusCode?: number}} [options]
30
+ * @param {{retryable?: boolean, statusCode?: number, retryAfterMs?: number}} [options]
32
31
  */
33
- constructor(code, message, { retryable = false, statusCode } = {}) {
32
+ constructor(code, message, { retryable = false, statusCode, retryAfterMs } = {}) {
34
33
  super(message);
35
34
  this.name = "WebFetchError";
36
35
  this.code = code;
37
36
  this.retryable = retryable;
38
37
  this.statusCode = statusCode;
38
+ this.retryAfterMs = retryAfterMs;
39
39
  }
40
40
  }
41
41
 
42
42
  /**
43
43
  * Compatibility wrapper for direct callers.
44
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]
45
+ * @param {{url: string, headers?: Record<string, string>, max_output_chars?: number, format?: string, render?: string, start_line?: number, max_lines?: number}} params
46
+ * @param {{documentOnly?: boolean, coordinator?: any, 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
47
  */
48
48
  export async function webFetchToolImpl(params, options = {}) {
49
49
  return (await performWebFetch(params, options)).text;
@@ -52,18 +52,41 @@ export async function webFetchToolImpl(params, options = {}) {
52
52
  /**
53
53
  * Fetch and locally extract one public URL.
54
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]
55
+ * @param {{url: string, headers?: Record<string, string>, max_output_chars?: number, format?: string, render?: string, start_line?: number, max_lines?: number}} params
56
+ * @param {{documentOnly?: boolean, coordinator?: any, 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]
57
57
  */
58
- export async function performWebFetch(
58
+ export async function performWebFetch(params, options = {}) {
59
+ const started = Date.now();
60
+ try {
61
+ return await withWebDeadline(options.signal, 45_000, async (signal) => {
62
+ const result = await performFetch(params, { ...options, signal });
63
+ if (signal.aborted && !result.error) return failure("Error: WebFetch was aborted or exceeded its deadline.", signal.reason?.code === "deadline_exceeded" ? "deadline_exceeded" : "aborted", started);
64
+ return result;
65
+ });
66
+ } catch (error) {
67
+ const normalized = webRequestFailure(error, "http", options.signal);
68
+ return failure(`Error: ${normalized.message}`, normalized.code, started, { retryAfterMs: normalized.retryAfterMs });
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Fetch and locally extract one public URL.
74
+ *
75
+ * @param {{url: string, headers?: Record<string, string>, max_output_chars?: number, format?: string, render?: string, start_line?: number, max_lines?: number}} params
76
+ * @param {{documentOnly?: boolean, coordinator?: any, 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]
77
+ */
78
+ async function performFetch(
59
79
  {
60
80
  url,
61
81
  headers = {},
62
82
  max_output_chars,
63
83
  format = "markdown",
64
84
  render,
85
+ start_line, max_lines,
65
86
  },
66
87
  {
88
+ coordinator,
89
+ documentOnly = false,
67
90
  sandboxPolicy,
68
91
  sandboxEngine,
69
92
  ctx,
@@ -77,6 +100,10 @@ export async function performWebFetch(
77
100
  } = {},
78
101
  ) {
79
102
  const startedAt = Date.now();
103
+ if ((start_line !== undefined && (!Number.isSafeInteger(start_line) || start_line < 1))
104
+ || (max_lines !== undefined && (!Number.isSafeInteger(max_lines) || max_lines < 1 || max_lines > 10_000))) {
105
+ return failure("Error: start_line must be positive; max_lines must be between 1 and 10000.", "invalid_range", startedAt);
106
+ }
80
107
  let parsed;
81
108
  try { parsed = new URL(url); } catch {
82
109
  return failure("Error: Invalid URL", "invalid_url", startedAt);
@@ -115,6 +142,52 @@ export async function performWebFetch(
115
142
  const resolvedCtx = ctx ?? readToolRuntime();
116
143
  const sandbox = resolvedCtx.sandbox ?? passthroughSandbox;
117
144
  const policy = resolveSandboxPolicy(resolvedCtx, sandboxPolicy);
145
+ if (requestedRender === "always") {
146
+ if (!sandbox.networkAllowsUrl(policy, parsed.href)) {
147
+ return failure("Error: Network access denied by sandbox policy.", "network_denied", startedAt, {
148
+ backend: "agent-browser", browserRecommended: true, renderReason: "explicit",
149
+ });
150
+ }
151
+ try {
152
+ const renderedResult = await coordinatedWebRequest(coordinator, "fetch", parsed.origin, signal, async () => ({
153
+ ok: true,
154
+ rendered: await browserRenderer(parsed.href, {
155
+ browserCommand: fetchSettings.browserCommand,
156
+ namespace,
157
+ sandboxPolicy: policy,
158
+ sandboxEngine,
159
+ ctx: resolvedCtx,
160
+ signal,
161
+ registerCleanup,
162
+ }),
163
+ }));
164
+ const rendered = normalizeBrowserResult(renderedResult.rendered, parsed.href);
165
+ assertNoWebAccessInterstitial({ url: rendered.finalUrl, text: rendered.text });
166
+ const renderedBody = outputFormat === "text" ? markdownToText(rendered.text) : rendered.text;
167
+ const document = {
168
+ body: renderedBody,
169
+ finalUrl: rendered.finalUrl,
170
+ outcome: {
171
+ status: "ok", code: "ok", retryable: false, attempts: 1,
172
+ backend: "agent-browser", cacheHit: false, durationMs: Date.now() - startedAt,
173
+ bytes: Buffer.byteLength(rendered.text, "utf8"), queueWaitMs: renderedResult.coordinationWaitMs,
174
+ backendDurationMs: renderedResult.backendDurationMs, truncated: renderedBody.length > maxChars,
175
+ redirectCount: 0, rendered: true, renderFailed: false, browserRecommended: false,
176
+ renderReason: "explicit", contentKind: "html", extractionStage: "browser", parserFailureCount: 0,
177
+ parserFailures: [],
178
+ },
179
+ };
180
+ return documentOnly ? { text: "", error: false, outcome: document.outcome, document }
181
+ : formatWebFetchDocument(document, { start_line, max_lines, max_output_chars: maxChars }, resolvedCtx);
182
+ } catch (error) {
183
+ const code = ["access_challenge", "authentication_required", "network_denied"].includes(error?.code)
184
+ ? error.code : "browser_render_failed";
185
+ return failure(`Error rendering URL: ${error?.message || String(error)}`, code, startedAt, {
186
+ attempts: 1, backend: "agent-browser", rendered: false, renderFailed: true,
187
+ browserRecommended: code === "browser_render_failed", renderReason: "explicit",
188
+ });
189
+ }
190
+ }
118
191
  const delays = Array.isArray(retryDelaysMs)
119
192
  ? retryDelaysMs.slice(0, 2).map((value) => Math.max(0, Number(value) || 0))
120
193
  : [];
@@ -123,18 +196,23 @@ export async function performWebFetch(
123
196
  let finalUrl = parsed.href;
124
197
  let redirectCount = 0;
125
198
  let responseBytes = 0;
199
+ let queueWaitMs = 0;
200
+ let backendDurationMs = 0;
126
201
  let bytes;
127
202
 
128
203
  for (let attempt = 0; attempt <= delays.length; attempt += 1) {
129
204
  attempts += 1;
130
205
  try {
131
206
  const fetched = await fetchFollowingRedirects(parsed, {
207
+ coordinator,
132
208
  headers: requestHeaders.headers,
133
209
  sandbox,
134
210
  policy,
135
211
  signal,
136
212
  fetchImpl,
137
213
  });
214
+ queueWaitMs += fetched.queueWaitMs;
215
+ backendDurationMs += fetched.backendDurationMs;
138
216
  response = fetched.response;
139
217
  finalUrl = fetched.url;
140
218
  redirectCount = fetched.redirects;
@@ -144,12 +222,12 @@ export async function performWebFetch(
144
222
  await waitForRetry(delay, signal);
145
223
  continue;
146
224
  }
147
- bytes = await readResponseBytes(response);
225
+ bytes = fetched.bytes;
148
226
  responseBytes = bytes.byteLength;
149
227
  break;
150
228
  } catch (error) {
151
229
  if (signal?.aborted) {
152
- return failure("Error fetching URL: request aborted", "aborted", startedAt, {
230
+ return failure("Error fetching URL: request aborted", signal.reason?.code === "deadline_exceeded" ? "deadline_exceeded" : "aborted", startedAt, {
153
231
  attempts,
154
232
  retryable: false,
155
233
  });
@@ -176,6 +254,9 @@ export async function performWebFetch(
176
254
  attempts,
177
255
  retryable: normalized.retryable,
178
256
  statusCode: normalized.statusCode ?? response?.status,
257
+ retryAfterMs: normalized.retryAfterMs, queueWaitMs, backendDurationMs,
258
+ browserRecommended: requestedRender === "auto"
259
+ && !["network_denied", "redirect_network_denied", "aborted", "deadline_exceeded"].includes(normalized.code),
179
260
  });
180
261
  }
181
262
  }
@@ -186,10 +267,46 @@ export async function performWebFetch(
186
267
 
187
268
  const contentType = response.headers.get("content-type") || "";
188
269
  const responseKind = contentKind(contentType, bytes);
270
+ let decodedForExtraction;
271
+ if (responseKind !== "binary") {
272
+ try {
273
+ decodedForExtraction = decodeWebBytes(bytes, contentType, responseKind);
274
+ assertNoWebAccessInterstitial({
275
+ url: finalUrl,
276
+ text: decodedForExtraction.text,
277
+ statusCode: response.status,
278
+ });
279
+ } catch (error) {
280
+ if (["access_challenge", "authentication_required"].includes(error?.code)) {
281
+ return failure(`Error fetching URL: ${error.message}`, error.code, startedAt, {
282
+ attempts,
283
+ statusCode: response.status,
284
+ bytes: responseBytes,
285
+ backend: "http",
286
+ redirectCount,
287
+ });
288
+ }
289
+ // Unsupported encodings are still handled by the extraction path below,
290
+ // where their decoding metadata and browser recommendation are retained.
291
+ decodedForExtraction = undefined;
292
+ }
293
+ } else if ([401, 407].includes(response.status)) {
294
+ try {
295
+ assertNoWebAccessInterstitial({ url: finalUrl, statusCode: response.status });
296
+ } catch (error) {
297
+ return failure(`Error fetching URL: ${error.message}`, error.code, startedAt, {
298
+ attempts,
299
+ statusCode: response.status,
300
+ bytes: responseBytes,
301
+ backend: "http",
302
+ redirectCount,
303
+ });
304
+ }
305
+ }
189
306
  if (!response.ok) {
190
307
  const preview = responseKind === "binary"
191
308
  ? "(binary response body omitted)"
192
- : decodeBytes(bytes, contentType).slice(0, 500);
309
+ : safeDecodePreview(bytes, contentType, responseKind);
193
310
  const errorText = [
194
311
  `HTTP ${response.status}`,
195
312
  `[BEGIN UNTRUSTED WEB ERROR BODY source=${JSON.stringify(finalUrl)}]`,
@@ -203,6 +320,7 @@ export async function performWebFetch(
203
320
  bytes: responseBytes,
204
321
  backend: "http",
205
322
  redirectCount,
323
+ browserRecommended: requestedRender === "auto" && [406, 415].includes(response.status),
206
324
  });
207
325
  }
208
326
  if (responseKind === "binary") {
@@ -216,14 +334,40 @@ export async function performWebFetch(
216
334
  }
217
335
 
218
336
  let extracted;
337
+ let decoding;
219
338
  try {
220
- extracted = await extractResponse(bytes, {
339
+ decoding = decodedForExtraction ?? decodeWebBytes(bytes, contentType, responseKind);
340
+ extracted = await extractWebDocument(bytes, {
221
341
  contentType,
222
342
  format: outputFormat,
223
343
  url: finalUrl,
224
344
  });
225
345
  } catch (error) {
226
- return failure(`Error extracting URL: ${error?.message || String(error)}`, "extraction_failed", startedAt, {
346
+ return failure(`Error extracting URL: ${error?.message || String(error)}`, error?.code || "extraction_failed", startedAt, {
347
+ attempts,
348
+ statusCode: response.status,
349
+ bytes: responseBytes,
350
+ backend: "http",
351
+ redirectCount,
352
+ browserRecommended: requestedRender === "auto" && responseKind === "html",
353
+ contentKind: responseKind,
354
+ ...(decoding === undefined ? {} : {
355
+ charset: decoding.charset,
356
+ charsetSource: decoding.charsetSource,
357
+ hadDecodingReplacement: decoding.hadDecodingReplacement,
358
+ }),
359
+ ...(Array.isArray(error?.parserFailures) ? { parserFailures: error.parserFailures.slice(0, 3) } : {}),
360
+ });
361
+ }
362
+
363
+ try {
364
+ assertNoWebAccessInterstitial({
365
+ url: finalUrl,
366
+ text: extracted.readableText,
367
+ statusCode: response.status,
368
+ });
369
+ } catch (error) {
370
+ return failure(`Error fetching URL: ${error.message}`, error.code, startedAt, {
227
371
  attempts,
228
372
  statusCode: response.status,
229
373
  bytes: responseBytes,
@@ -235,13 +379,13 @@ export async function performWebFetch(
235
379
  const shouldRender = responseKind === "html"
236
380
  && (
237
381
  requestedRender === "always"
238
- || (requestedRender === "auto" && shouldAutoRender(extracted.readableText, decodeBytes(bytes, contentType)))
382
+ || (requestedRender === "auto" && shouldAutoRender(extracted.readableText, decodedText(bytes, contentType, responseKind)))
239
383
  );
240
384
  let backend = "http";
241
385
  let renderFailed = false;
242
386
  if (shouldRender) {
243
387
  try {
244
- const rendered = await browserRenderer(finalUrl, {
388
+ const renderedResult = await coordinatedWebRequest(coordinator, "fetch", new URL(finalUrl).origin, signal, async () => ({ ok: true, rendered: await browserRenderer(finalUrl, {
245
389
  browserCommand: fetchSettings.browserCommand,
246
390
  namespace,
247
391
  sandboxPolicy,
@@ -249,16 +393,32 @@ export async function performWebFetch(
249
393
  ctx: resolvedCtx,
250
394
  signal,
251
395
  registerCleanup,
252
- });
396
+ }) }));
397
+ queueWaitMs += renderedResult.coordinationWaitMs;
398
+ backendDurationMs += renderedResult.backendDurationMs;
399
+ const rendered = normalizeBrowserResult(renderedResult.rendered, finalUrl);
400
+ assertNoWebAccessInterstitial({ url: rendered.finalUrl, text: rendered.text });
401
+ signal?.throwIfAborted();
253
402
  extracted = {
254
- body: outputFormat === "text" ? markdownToText(rendered) : rendered,
255
- readableText: markdownToText(rendered),
403
+ body: outputFormat === "text" ? markdownToText(rendered.text) : rendered.text,
404
+ readableText: markdownToText(rendered.text),
256
405
  title: extracted.title,
406
+ charset: extracted.charset,
407
+ charsetSource: extracted.charsetSource,
408
+ hadDecodingReplacement: extracted.hadDecodingReplacement,
409
+ extractionStage: "browser",
410
+ parserFailureCount: extracted.parserFailureCount,
411
+ parserFailures: extracted.parserFailures,
257
412
  };
413
+ finalUrl = rendered.finalUrl;
258
414
  backend = "agent-browser";
259
415
  } catch (error) {
260
- if (requestedRender === "always") {
261
- return failure(`Error rendering URL: ${error?.message || String(error)}`, "browser_render_failed", startedAt, {
416
+ if (signal?.aborted) return failure("Error: WebFetch rendering was aborted.", "aborted", startedAt);
417
+ const terminalCode = ["access_challenge", "authentication_required", "network_denied"].includes(error?.code)
418
+ ? error.code : null;
419
+ if (terminalCode || requestedRender === "always" || error?.code === "coordination_unavailable") {
420
+ const code = terminalCode ?? "browser_render_failed";
421
+ return failure(`Error rendering URL: ${error?.message || String(error)}`, code, startedAt, {
262
422
  attempts,
263
423
  statusCode: response.status,
264
424
  bytes: responseBytes,
@@ -270,15 +430,11 @@ export async function performWebFetch(
270
430
  }
271
431
  }
272
432
 
433
+ if (responseKind === "html" && backend === "http" && shouldAutoRender(extracted.readableText, decodedText(bytes, contentType, responseKind))) {
434
+ return failure("Error: Page contains an unusable loading shell; no readable evidence was retrieved.", "unusable_content", startedAt, { backend, rendered: false, renderFailed, browserRecommended: true });
435
+ }
273
436
  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,
437
+ const document = { body, finalUrl,
282
438
  outcome: {
283
439
  status: "ok",
284
440
  code: renderFailed ? "ok_static_render_failed" : "ok",
@@ -288,19 +444,37 @@ export async function performWebFetch(
288
444
  cacheHit: false,
289
445
  durationMs: Date.now() - startedAt,
290
446
  bytes: responseBytes,
447
+ queueWaitMs, backendDurationMs,
291
448
  truncated: body.length > maxChars,
292
449
  statusCode: response.status,
293
450
  redirectCount,
294
451
  rendered: backend === "agent-browser",
295
452
  renderFailed,
453
+ browserRecommended: renderFailed,
454
+ ...(backend === "agent-browser" ? { renderReason: "sparse_html" } : {}),
296
455
  contentKind: responseKind,
456
+ charset: extracted.charset,
457
+ charsetSource: extracted.charsetSource,
458
+ hadDecodingReplacement: extracted.hadDecodingReplacement,
459
+ extractionStage: extracted.extractionStage,
460
+ parserFailureCount: extracted.parserFailureCount ?? 0,
461
+ parserFailures: extracted.parserFailures ?? [],
297
462
  },
298
- error: false,
299
- };
463
+ };
464
+ return documentOnly ? { text: "", error: false, outcome: document.outcome, document }
465
+ : formatWebFetchDocument(document, { start_line, max_lines, max_output_chars: maxChars }, resolvedCtx);
466
+ }
467
+
468
+ function normalizeBrowserResult(value, requestedUrl) {
469
+ if (typeof value === "string") return { text: value, finalUrl: requestedUrl };
470
+ if (value && typeof value.text === "string" && typeof value.finalUrl === "string") return value;
471
+ throw Object.assign(new Error("Browser renderer returned an invalid result."), { code: "browser_render_failed" });
300
472
  }
301
473
 
302
474
  async function fetchFollowingRedirects(initialUrl, options) {
303
475
  let current = new URL(initialUrl.href);
476
+ let queueWaitMs = 0;
477
+ let backendDurationMs = 0;
304
478
  for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
305
479
  if (!options.sandbox.networkAllowsUrl(options.policy, current.href)) {
306
480
  throw new WebFetchError(
@@ -310,14 +484,25 @@ async function fetchFollowingRedirects(initialUrl, options) {
310
484
  : "Network access denied by sandbox policy (redirect).",
311
485
  );
312
486
  }
313
- const response = await options.fetchImpl(current, {
314
- headers: options.headers,
315
- redirect: "manual",
316
- signal: requestSignal(options.signal),
317
- });
487
+ const fetched = await coordinatedWebRequest(options.coordinator, "fetch", current.origin, options.signal, async () => {
488
+ const response = await options.fetchImpl(current, {
489
+ headers: options.headers, redirect: "manual", signal: requestSignal(options.signal),
490
+ });
491
+ const redirect = response.status >= 300 && response.status < 400 && response.headers.has("location");
492
+ let bytes;
493
+ if (redirect) { await response.body?.cancel(); bytes = new Uint8Array(); }
494
+ else bytes = await readResponseBytes(response);
495
+ return { response, bytes };
496
+ }, ({ response }) => ({
497
+ status: response.status === 429 ? "rate_limited" : response.status >= 500 ? "unavailable" : "ok",
498
+ ...(response.status === 429 ? { retryAfterMs: retryAfterMilliseconds(response) } : {}),
499
+ }));
500
+ queueWaitMs += fetched.coordinationWaitMs;
501
+ backendDurationMs += fetched.backendDurationMs;
502
+ const { response, bytes } = fetched;
318
503
  const location = response.headers.get("location");
319
504
  if (response.status < 300 || response.status >= 400 || !location) {
320
- return { response, url: current.href, redirects: hop };
505
+ return { response, bytes, url: current.href, redirects: hop, queueWaitMs, backendDurationMs };
321
506
  }
322
507
  if (hop === MAX_REDIRECTS) {
323
508
  try { await response.body?.cancel(); } catch { /* best effort */ }
@@ -368,166 +553,13 @@ async function readResponseBytes(response) {
368
553
  return new Uint8Array(Buffer.concat(chunks));
369
554
  }
370
555
 
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;
556
+ function decodedText(bytes, contentType, kind) {
557
+ return decodeWebBytes(bytes, contentType, kind).text;
521
558
  }
522
559
 
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
- }
560
+ function safeDecodePreview(bytes, contentType, kind) {
561
+ try { return decodedText(bytes, contentType, kind).slice(0, 500); }
562
+ catch { return new TextDecoder("utf-8", { fatal: false }).decode(bytes).slice(0, 500); }
531
563
  }
532
564
 
533
565
  function normalizeRequestHeaders(headers) {
@@ -568,51 +600,6 @@ function normalizeFetchConfig(input) {
568
600
  return { render, browserCommand: browserCommand.trim() };
569
601
  }
570
602
 
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
603
  function requestSignal(signal) {
617
604
  const timeout = AbortSignal.timeout(FETCH_TIMEOUT_MS);
618
605
  return signal ? AbortSignal.any([signal, timeout]) : timeout;
@@ -662,7 +649,7 @@ function normalizeFetchError(error) {
662
649
  return new WebFetchError(
663
650
  timedOut ? "timeout" : (aborted ? "aborted" : (code || "request_failed")),
664
651
  error?.message || String(error),
665
- { retryable: transient },
652
+ { retryable: transient, retryAfterMs: error?.retryAfterMs },
666
653
  );
667
654
  }
668
655
 
@@ -689,3 +676,44 @@ function positiveInteger(value, fallback) {
689
676
  const number = Number(value);
690
677
  return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
691
678
  }
679
+
680
+ function retryAfterMilliseconds(response) {
681
+ const raw = response.headers.get("retry-after");
682
+ if (!raw) return undefined;
683
+ const seconds = Number(raw);
684
+ const delay = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(raw) - Date.now();
685
+ return Number.isFinite(delay) ? Math.max(0, delay) : undefined;
686
+ }
687
+
688
+ export function formatWebFetchDocument(document, params, ctx) {
689
+ if ((params.start_line !== undefined && (!Number.isSafeInteger(params.start_line) || params.start_line < 1))
690
+ || (params.max_lines !== undefined && (!Number.isSafeInteger(params.max_lines) || params.max_lines < 1 || params.max_lines > 10000))) {
691
+ return failure("Error: Invalid WebFetch line range.", "invalid_range", Date.now());
692
+ }
693
+ const { body, finalUrl } = document;
694
+ const ranged = params.start_line !== undefined || params.max_lines !== undefined;
695
+ const lines = body.split("\n");
696
+ const start = params.start_line ?? 1;
697
+ const count = params.max_lines ?? 200;
698
+ const selected = ranged ? lines.slice(start - 1, start - 1 + count).join("\n") : body;
699
+ const maxChars = positiveInteger(params.max_output_chars, DEFAULT_MAX_TOOL_OUTPUT_CHARS);
700
+ const capped = capChars(selected, { label: "WebFetch", maxChars, ctx });
701
+ const shownLines = capped === selected ? (selected ? selected.split("\n").length : 0)
702
+ : Math.max(0, capped.slice(0, capped.lastIndexOf("[truncated WebFetch output:")).split("\n").length - 1);
703
+ const end = Math.min(lines.length, start - 1 + shownLines);
704
+ const continuation = end < lines.length ? Math.max(start, end + 1) : null;
705
+ const continuationHint = continuation === start && capped !== selected
706
+ ? `The next line exceeds the output budget. Increase max_output_chars or read the saved output artifact; repeating this range with the same budget cannot advance.`
707
+ : `Continue with WebFetch url=${JSON.stringify(finalUrl)} start_line=${continuation} max_lines=${count}.`;
708
+ return {
709
+ text: [`[BEGIN UNTRUSTED WEB CONTENT source=${JSON.stringify(finalUrl)}]`,
710
+ ...(ranged || continuation ? [`[Lines ${start}-${end} of ${lines.length}.]`] : []),
711
+ capped,
712
+ ...(continuation ? [`[${continuationHint}]`] : []),
713
+ "[END UNTRUSTED WEB CONTENT]"].join("\n"),
714
+ outcome: { ...document.outcome, truncated: selected.length > maxChars || end < lines.length,
715
+ startLine: start, endLine: end, totalLines: lines.length, nextLine: continuation },
716
+ error: false,
717
+ document,
718
+ };
719
+ }