@mono-agent/agent-runtime 0.20.14 → 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 (82) hide show
  1. package/ARCHITECTURE.md +50 -11
  2. package/MIGRATION.md +30 -7
  3. package/README.md +219 -35
  4. package/package.json +9 -4
  5. package/src/agent/tool-bloat.js +145 -9
  6. package/src/agent/tools/agent-tool.js +104 -5
  7. package/src/agent/tools/bash.js +10 -2
  8. package/src/agent/tools/codex-subscription-search.js +122 -28
  9. package/src/agent/tools/exec.js +10 -2
  10. package/src/agent/tools/monitor.js +11 -2
  11. package/src/agent/tools/pi-bridge.js +33 -14
  12. package/src/agent/tools/shared/monitors.js +22 -3
  13. package/src/agent/tools/shared/path-resolver.js +25 -6
  14. package/src/agent/tools/shared/process-jobs.js +6 -1
  15. package/src/agent/tools/shared/process-runner.js +3 -1
  16. package/src/agent/tools/shared/tool-context.js +8 -0
  17. package/src/agent/tools/web-access-interstitial.js +70 -0
  18. package/src/agent/tools/web-browser-render.js +83 -58
  19. package/src/agent/tools/web-controller.js +112 -21
  20. package/src/agent/tools/web-document-extractor.js +379 -0
  21. package/src/agent/tools/web-fetch.js +271 -243
  22. package/src/agent/tools/web-request.js +65 -0
  23. package/src/agent/tools/web-search-output.js +165 -0
  24. package/src/agent/tools/web-search-state.js +75 -0
  25. package/src/agent/tools/web-search.js +532 -71
  26. package/src/ai/failure.js +3 -3
  27. package/src/ai/index.js +1 -0
  28. package/src/ai/observer.js +8 -0
  29. package/src/ai/pi-interop.js +156 -0
  30. package/src/ai/provider-check.js +131 -0
  31. package/src/ai/providers/pi-native/compaction-driver.js +45 -21
  32. package/src/ai/providers/pi-native/compaction-summary.js +140 -0
  33. package/src/ai/providers/pi-native/harness-adapter.js +40 -2
  34. package/src/ai/providers/pi-native/prompt-cache-diagnostics.js +103 -0
  35. package/src/ai/providers/pi-native/provider-attribution.js +102 -0
  36. package/src/ai/providers/pi-native/result-builder.js +28 -4
  37. package/src/ai/providers/pi-native/session-lifecycle.js +167 -24
  38. package/src/ai/providers/pi-native/stream-subscriber.js +30 -2
  39. package/src/ai/providers/pi-native/terminal-recovery.js +40 -0
  40. package/src/ai/providers/pi-native/turn-runner.js +245 -13
  41. package/src/ai/providers/pi-native.js +159 -40
  42. package/src/ai/runtime/live-input-events.js +250 -54
  43. package/src/ai/runtime/router.js +30 -11
  44. package/src/ai/tool-lifecycle.js +32 -18
  45. package/src/ai/types.js +26 -5
  46. package/src/runtime.js +24 -5
  47. package/types/agent/tool-bloat.d.ts +1 -1
  48. package/types/agent/tools/agent-tool.d.ts +4 -1
  49. package/types/agent/tools/bash.d.ts +5 -3
  50. package/types/agent/tools/codex-subscription-search.d.ts +6 -2
  51. package/types/agent/tools/exec.d.ts +5 -3
  52. package/types/agent/tools/monitor.d.ts +5 -2
  53. package/types/agent/tools/pi-bridge.d.ts +6 -4
  54. package/types/agent/tools/shared/monitors.d.ts +17 -2
  55. package/types/agent/tools/shared/process-jobs.d.ts +5 -1
  56. package/types/agent/tools/shared/process-runner.d.ts +3 -2
  57. package/types/agent/tools/shared/tool-context.d.ts +2 -0
  58. package/types/agent/tools/web-access-interstitial.d.ts +23 -0
  59. package/types/agent/tools/web-browser-render.d.ts +4 -1
  60. package/types/agent/tools/web-controller.d.ts +4 -2
  61. package/types/agent/tools/web-document-extractor.d.ts +27 -0
  62. package/types/agent/tools/web-fetch.d.ts +19 -24
  63. package/types/agent/tools/web-request.d.ts +20 -0
  64. package/types/agent/tools/web-search-output.d.ts +31 -0
  65. package/types/agent/tools/web-search-state.d.ts +21 -0
  66. package/types/agent/tools/web-search.d.ts +10 -45
  67. package/types/ai/index.d.ts +1 -0
  68. package/types/ai/observer.d.ts +6 -0
  69. package/types/ai/pi-interop.d.ts +61 -0
  70. package/types/ai/provider-check.d.ts +53 -0
  71. package/types/ai/providers/pi-native/compaction-driver.d.ts +2 -1
  72. package/types/ai/providers/pi-native/compaction-summary.d.ts +19 -0
  73. package/types/ai/providers/pi-native/harness-adapter.d.ts +3 -1
  74. package/types/ai/providers/pi-native/prompt-cache-diagnostics.d.ts +3 -0
  75. package/types/ai/providers/pi-native/provider-attribution.d.ts +26 -0
  76. package/types/ai/providers/pi-native/result-builder.d.ts +11 -1
  77. package/types/ai/providers/pi-native/session-lifecycle.d.ts +23 -5
  78. package/types/ai/providers/pi-native/terminal-recovery.d.ts +2 -0
  79. package/types/ai/providers/pi-native/turn-runner.d.ts +36 -5
  80. package/types/ai/runtime/live-input-events.d.ts +32 -8
  81. package/types/ai/tool-lifecycle.d.ts +4 -3
  82. package/types/ai/types.d.ts +140 -12
@@ -1,11 +1,22 @@
1
1
  // @ts-check
2
2
 
3
+ import { withWebDeadline, coordinatedWebRequest, webRequestFailure } from "./web-request.js";
4
+ import { isIP } from "node:net";
3
5
  import { parseHTML } from "linkedom";
4
6
  import { passthroughSandbox } from "../sandbox-seam.js";
5
7
  import { searchCodexSubscription } from "./codex-subscription-search.js";
6
8
  import { readToolRuntime } from "./shared/runtime-context.js";
7
9
  import { createCountingSemaphore } from "./shared/semaphore.js";
8
10
  import { resolveSandboxPolicy } from "./shared/tool-context.js";
11
+ import { renderBoundedWebSearchBody } from "./web-search-output.js";
12
+ import {
13
+ claimWebSearchRequest,
14
+ createWebSearchRunState,
15
+ deferWebSearchProvider,
16
+ deferredWebSearchProvider,
17
+ MAX_WEB_SEARCH_REQUESTS_PER_RUN,
18
+ webSearchBudgetSnapshot,
19
+ } from "./web-search-state.js";
9
20
 
10
21
  const SEARCH_TIMEOUT_MS = 15_000;
11
22
  const SEARCH_RESPONSE_MAX_BYTES = 2 * 1024 * 1024;
@@ -56,6 +67,8 @@ let keylessSemaphore = createCountingSemaphore(keylessThrottle.maxConcurrency);
56
67
  const backendCooldownUntil = new Map();
57
68
  /** @type {Map<string, number>} Backend -> epoch ms its next request may start. */
58
69
  const backendNextAvailableAt = new Map();
70
+ /** @type {Map<string, ReturnType<typeof createCountingSemaphore>>} */
71
+ const processProviderSemaphores = new Map();
59
72
  const TRACKING_PARAMETERS = new Set([
60
73
  "dclid",
61
74
  "fbclid",
@@ -75,21 +88,33 @@ const TRACKING_PARAMETERS = new Set([
75
88
  * Compatibility wrapper for direct callers.
76
89
  *
77
90
  * @param {{query: string, limit?: number, alternate_queries?: string[], domains?: string[], exclude_domains?: string[], language?: string, time_range?: string}} params
78
- * @param {{sandboxPolicy?: any, ctx?: any, signal?: AbortSignal, searchConfig?: any, fetchImpl?: typeof fetch}} [options]
91
+ * @param {{sandboxPolicy?: any, ctx?: any, signal?: AbortSignal, coordinator?: any, searchConfig?: any, fetchImpl?: typeof fetch}} [options]
79
92
  */
80
93
  export async function webSearchToolImpl(params, options = {}) {
81
94
  return (await performWebSearch(params, options)).text;
82
95
  }
83
96
 
97
+ /**
98
+ * Search through an explicitly selected Ollama endpoint, an operator-owned
99
+ * SearXNG endpoint, ChatGPT-subscription Codex search, and/or the keyless HTML fallback chain. Returns a structured
100
+ * internal outcome for the Pi bridge.
101
+ *
102
+ * @param {{query: string, limit?: number, alternate_queries?: string[], domains?: string[], exclude_domains?: string[], language?: string, time_range?: string}} params
103
+ * @param {{sandboxPolicy?: any, ctx?: any, signal?: AbortSignal, coordinator?: any, searchConfig?: any, searchState?: any, fetchImpl?: typeof fetch, codexSearch?: typeof searchCodexSubscription}} [options]
104
+ */
105
+ export async function performWebSearch(params, options = {}) {
106
+ return await withWebDeadline(options.signal, 60_000, (signal) => performSearch(params, { ...options, signal }));
107
+ }
108
+
84
109
  /**
85
110
  * Search through an operator-owned SearXNG endpoint, ChatGPT-subscription
86
111
  * Codex search, and/or the keyless HTML fallback chain. Returns a structured
87
112
  * internal outcome for the Pi bridge.
88
113
  *
89
114
  * @param {{query: string, limit?: number, alternate_queries?: string[], domains?: string[], exclude_domains?: string[], language?: string, time_range?: string}} params
90
- * @param {{sandboxPolicy?: any, ctx?: any, signal?: AbortSignal, searchConfig?: any, fetchImpl?: typeof fetch, codexSearch?: typeof searchCodexSubscription}} [options]
115
+ * @param {{sandboxPolicy?: any, ctx?: any, signal?: AbortSignal, coordinator?: any, searchConfig?: any, searchState?: any, fetchImpl?: typeof fetch, codexSearch?: typeof searchCodexSubscription}} [options]
91
116
  */
92
- export async function performWebSearch(
117
+ async function performSearch(
93
118
  {
94
119
  query,
95
120
  limit = 5,
@@ -104,21 +129,31 @@ export async function performWebSearch(
104
129
  ctx,
105
130
  signal,
106
131
  searchConfig,
132
+ searchState: suppliedSearchState,
133
+ coordinator,
107
134
  fetchImpl = globalThis.fetch,
108
135
  codexSearch = searchCodexSubscription,
109
136
  } = {},
110
137
  ) {
111
138
  const startedAt = Date.now();
139
+ const searchState = createWebSearchRunState(searchConfig, suppliedSearchState);
140
+ const callClaims = { requests: 0 };
112
141
  const normalizedQuery = typeof query === "string" ? query.trim() : "";
113
142
  if (!normalizedQuery) {
114
- return failure("Error: WebSearch query must not be empty.", "invalid_query", startedAt);
143
+ return searchFailure("Error: WebSearch query must not be empty.", "invalid_query", startedAt, searchState, callClaims.requests);
115
144
  }
116
145
  const max = clampInteger(limit, 1, 10, 5);
117
146
  const explicitDomains = normalizeDomains(Array.isArray(domains) ? domains : []);
118
147
  const includeDomains = normalizeDomains([...explicitDomains, ...querySiteDomains(normalizedQuery)]);
119
148
  const excludeDomains = normalizeDomains(exclude_domains);
120
149
  const config = normalizeSearchConfig(searchConfig);
121
- if (config.error) return failure(`Error: ${config.error}`, "invalid_search_config", startedAt);
150
+ if ("error" in config) return searchFailure(
151
+ `Error: ${config.error}`,
152
+ "code" in config ? config.code : "invalid_search_config",
153
+ startedAt,
154
+ searchState,
155
+ callClaims.requests,
156
+ );
122
157
 
123
158
  const resolvedCtx = ctx ?? readToolRuntime();
124
159
  const sandbox = resolvedCtx.sandbox ?? passthroughSandbox;
@@ -135,29 +170,42 @@ export async function performWebSearch(
135
170
  const actualQueries = [];
136
171
  let attempts = 0;
137
172
  let anyProviderSucceeded = false;
173
+ let queueWaitMs = 0;
174
+ let backendDurationMs = 0;
138
175
 
139
- const runQuery = async (candidate, backend) => {
176
+ const runQuery = async (candidate, backend, stageSignal = signal) => {
140
177
  attempts += 1;
141
178
  attemptedBackends.add(backend);
142
179
  return await searchOneQuery(
143
180
  queryWithDomains(candidate, explicitDomains),
144
181
  {
145
182
  config: { ...config, backend },
183
+ coordinator,
184
+ relevanceQuery: normalizedQuery, includeDomains, excludeDomains,
185
+ auto: config.backend === "auto",
146
186
  language,
147
187
  timeRange: time_range,
148
188
  sandbox,
149
189
  policy,
150
- signal,
190
+ signal: stageSignal,
151
191
  fetchImpl,
152
192
  codexSearch,
193
+ searchState,
194
+ callClaims,
195
+ maxResults: max,
153
196
  },
154
197
  );
155
198
  };
156
199
  const recordResult = (result) => {
200
+ queueWaitMs += result.coordinationWaitMs || 0;
201
+ backendDurationMs += result.backendDurationMs || 0;
157
202
  // Chain failures are reported even when a later backend rescued the query,
158
203
  // so a silent degradation to the fallback is still visible in the outcome.
159
204
  if (result.failures?.length) providerFailures.push(...result.failures);
160
205
  if (result.ok) {
206
+ if (typeof result.actualQuery === "string" && result.actualQuery.trim()) {
207
+ actualQueries.push(result.actualQuery.trim());
208
+ }
161
209
  const filtered = filterRelevantResults(
162
210
  filterByDomains(result.results, includeDomains, excludeDomains),
163
211
  normalizedQuery,
@@ -166,6 +214,7 @@ export async function performWebSearch(
166
214
  anyProviderSucceeded = true;
167
215
  providersUsed.add(result.backend);
168
216
  rankedLists.push(filtered);
217
+ return true;
169
218
  } else if (result.results.length === 0) {
170
219
  anyProviderSucceeded = true;
171
220
  } else {
@@ -177,40 +226,61 @@ export async function performWebSearch(
177
226
  relevance: true,
178
227
  });
179
228
  }
180
- if (typeof result.actualQuery === "string" && result.actualQuery.trim()) {
181
- actualQueries.push(result.actualQuery.trim());
182
- }
183
229
  } else if (!result.failures?.length) {
184
230
  providerFailures.push(result);
185
231
  }
232
+ return false;
186
233
  };
187
234
 
188
- const runStage = async (backend, candidates) => {
189
- const results = await Promise.all(candidates.map((candidate) => runQuery(candidate, backend)));
190
- results.forEach(recordResult);
191
- return mergeRankedResults(rankedLists, max);
192
- };
193
-
235
+ // A broad primary query walks the eligible provider chain before any
236
+ // alternate wording. Auto includes Ollama only when its block was explicitly
237
+ // configured; all named backend modes remain strict.
238
+ const eligibleBackends = config.backend === "auto"
239
+ ? [...(config.ollama ? ["ollama"] : []), ...(config.endpoint ? ["searxng"] : []), "codex", "keyless"]
240
+ : [config.backend];
241
+ const disabledForCall = new Set();
194
242
  let merged = [];
195
- if (config.backend === "searxng" || (config.backend === "auto" && config.endpoint)) {
196
- merged = await runStage("searxng", initialQueries);
197
- }
198
- if (config.backend === "codex" || (config.backend === "auto" && merged.length === 0)) {
199
- // Exactly one subscription turn per WebSearch call. Alternate queries still
200
- // help local/keyless rank fusion but never multiply paid subscription work.
201
- merged = await runStage("codex", [normalizedQuery]);
202
- }
203
- if (config.backend === "keyless" || (config.backend === "auto" && merged.length === 0)) {
204
- merged = await runStage("keyless", initialQueries);
243
+ for (let queryIndex = 0; queryIndex < initialQueries.length && merged.length === 0; queryIndex += 1) {
244
+ const candidate = initialQueries[queryIndex];
245
+ for (const backend of eligibleBackends) {
246
+ if (signal?.aborted || disabledForCall.has(backend)) continue;
247
+ // Subscription search remains exactly one turn per WebSearch call.
248
+ if (backend === "codex" && queryIndex > 0) continue;
249
+ const run = (stageSignal) => runQuery(candidate, backend, stageSignal);
250
+ const result = backend === "searxng" && config.backend === "auto"
251
+ ? await withWebDeadline(signal, 3000, run)
252
+ : await run(signal);
253
+ const usable = recordResult(result);
254
+ if (usable) {
255
+ merged = mergeRankedResults(rankedLists, max);
256
+ break;
257
+ }
258
+ if (!result.ok && !result.relevance) disabledForCall.add(backend);
259
+ if (providerFailures.some((entry) => ["coordination_unavailable", "search_budget_exhausted"].includes(entry.code))) break;
260
+ }
261
+ if (providerFailures.some((entry) => ["coordination_unavailable", "search_budget_exhausted"].includes(entry.code))) break;
205
262
  }
206
263
  if (signal?.aborted) {
207
- return failure("Error: WebSearch was aborted.", "aborted", startedAt, {
264
+ return searchFailure("Error: WebSearch was aborted or exceeded its deadline.", signal.reason?.code === "deadline_exceeded" ? "deadline_exceeded" : "aborted", startedAt, searchState, callClaims.requests, {
208
265
  attempts,
209
266
  retryable: false,
210
267
  });
211
268
  }
212
269
 
213
- if (!anyProviderSucceeded) {
270
+ if (providerFailures.some((r) => r.code === "coordination_unavailable")) {
271
+ return searchFailure("Error: Web request coordination is unavailable; no uncoordinated fallback was attempted.", "coordination_unavailable", startedAt, searchState, callClaims.requests, { attempts });
272
+ }
273
+ if (providerFailures.some((entry) => entry.code === "search_budget_exhausted")) {
274
+ return searchFailure("Error: WebSearch request budget exhausted for this run.", "search_budget_exhausted", startedAt, searchState, callClaims.requests, {
275
+ attempts,
276
+ backend: config.backend,
277
+ attemptedBackends: [...attemptedBackends],
278
+ providerAttempts: providerAttemptMetadata(providerFailures),
279
+ retryInRun: false,
280
+ nextAction: "use_available_evidence",
281
+ });
282
+ }
283
+ if (!anyProviderSucceeded || (merged.length === 0 && providerFailures.some((entry) => !entry.relevance))) {
214
284
  // Four query variants against two backends produce the same handful of
215
285
  // messages over and over; dedupe so the reason stays readable.
216
286
  const reason = [...new Set(providerFailures.map((entry) => entry.message).filter(Boolean))].join("; ")
@@ -218,30 +288,42 @@ export async function performWebSearch(
218
288
  const networkDenied = providerFailures.length > 0
219
289
  && providerFailures.every((entry) => entry.message === "Network access denied by sandbox policy.");
220
290
  const throttled = providerFailures.some((entry) => entry.rateLimited || entry.cooldown);
221
- return failure(networkDenied
291
+ const strictProviderCode = config.backend !== "auto"
292
+ ? providerFailures.find((entry) => typeof entry.code === "string")?.code
293
+ : undefined;
294
+ const retryAfterMs = shortestRetry(providerFailures);
295
+ const retryAt = earliestRetryAt(providerFailures);
296
+ return searchFailure(networkDenied
222
297
  ? "Error: Network access denied by sandbox policy."
223
298
  : `Error: WebSearch failed: ${reason}`,
224
- networkDenied ? "network_denied" : (throttled ? "rate_limited" : "backend_unavailable"), startedAt, {
299
+ networkDenied ? "network_denied" : (throttled ? "rate_limited" : (strictProviderCode || "backend_unavailable")), startedAt, searchState, callClaims.requests, {
225
300
  attempts,
226
301
  backend: config.backend,
227
302
  retryable: providerFailures.some((entry) => entry.retryable),
228
303
  rateLimited: throttled,
229
- cooldownBackends: [...backendCooldownUntil.keys()],
304
+ cooldownBackends: cooldownBackendNames(searchState),
230
305
  attemptedBackends: [...attemptedBackends],
231
306
  failureMetadata: sanitizeFailureMetadata(providerFailures),
307
+ queueWaitMs, backendDurationMs,
308
+ cooldownSkipCount: providerFailures.filter((r) => r.cooldown).length,
309
+ quotaSkipCount: providerFailures.filter((r) => r.quotaSkipped).length,
310
+ retryAfterMs,
311
+ ...(retryAt === undefined ? {} : { retryAt: new Date(retryAt).toISOString() }),
312
+ retryInRun: false,
313
+ nextAction: "use_available_evidence",
314
+ providerAttempts: providerAttemptMetadata(providerFailures),
232
315
  });
233
316
  }
234
317
 
235
318
  const backend = providersUsed.size === 1
236
319
  ? [...providersUsed][0]
237
320
  : providersUsed.size > 1 ? "mixed" : config.backend;
238
- const body = merged.length === 0
239
- ? "No results."
240
- : merged.map((result, index) => {
241
- const snippet = result.snippet ? `\n ${collapseWhitespace(result.snippet)}` : "";
242
- return `${index + 1}. [${escapeMarkdownLabel(result.title || result.url)}](${result.url})${snippet}`;
243
- }).join("\n\n");
321
+ const rendered = renderBoundedWebSearchBody(merged);
322
+ const nextAction = rendered.renderedResultCount > 0
323
+ ? "fetch_existing_sources"
324
+ : searchState.requestsUsed < searchState.maxRequests ? "refine_query" : "use_available_evidence";
244
325
  const text = [
326
+ searchControlLine(searchState, callClaims.requests, providerFailures, nextAction),
245
327
  "[BEGIN UNTRUSTED WEB SEARCH RESULTS]",
246
328
  searchMetadataLine({
247
329
  backend,
@@ -249,28 +331,38 @@ export async function performWebSearch(
249
331
  query: actualQueries[0] || normalizedQuery,
250
332
  providerFailures,
251
333
  }),
252
- body,
334
+ ...(language || time_range ? [`[Requested filters: language=${JSON.stringify(collapseWhitespace(language || "default").slice(0, 100))}; time_range=${collapseWhitespace(time_range || "any").slice(0, 100)}; provider-dependent, verify dates in sources.]`] : []),
335
+ rendered.body,
253
336
  "[END UNTRUSTED WEB SEARCH RESULTS]",
254
337
  ].join("\n");
255
338
  return {
256
339
  text,
257
340
  outcome: {
258
341
  status: "ok",
259
- code: merged.length === 0 ? "no_results" : "ok",
342
+ code: rendered.renderedResultCount === 0 ? "no_results" : "ok",
260
343
  retryable: false,
261
344
  attempts,
262
345
  backend,
263
346
  cacheHit: false,
264
347
  durationMs: Date.now() - startedAt,
265
348
  bytes: Buffer.byteLength(text, "utf8"),
266
- truncated: false,
267
- resultCount: merged.length,
349
+ truncated: rendered.truncated,
350
+ resultCount: rendered.renderedResultCount,
351
+ queueWaitMs, backendDurationMs,
352
+ cooldownSkipCount: providerFailures.filter((r) => r.cooldown).length,
353
+ quotaSkipCount: providerFailures.filter((r) => r.quotaSkipped).length,
354
+ filterSupport: { language: language ? (backend === "searxng" ? "provider" : "advisory") : "not_requested", timeRange: time_range ? (["codex", "ollama", "startpage"].includes(backend) ? "advisory" : "provider") : "not_requested" },
268
355
  providerFailureCount: providerFailures.length,
269
356
  rateLimited: providerFailures.some((entry) => entry.rateLimited || entry.cooldown),
270
- cooldownBackends: [...backendCooldownUntil.keys()],
357
+ cooldownBackends: cooldownBackendNames(searchState),
271
358
  attemptedBackends: [...attemptedBackends],
272
359
  actualQueries: uniqueStrings(actualQueries.length > 0 ? actualQueries : [normalizedQuery], 4),
273
360
  failureMetadata: sanitizeFailureMetadata(providerFailures),
361
+ ...webSearchBudgetSnapshot(searchState, callClaims.requests),
362
+ fallbackUsed: attemptedBackends.size > 1,
363
+ retryInRun: searchState.requestsUsed < searchState.maxRequests,
364
+ nextAction,
365
+ providerAttempts: providerAttemptMetadata(providerFailures),
274
366
  },
275
367
  error: false,
276
368
  };
@@ -294,10 +386,20 @@ async function searchOneQuery(query, options) {
294
386
  let emptySuccess = null;
295
387
  if (options.signal?.aborted) return abortedSearch(config.backend, failures);
296
388
  if (config.backend === "searxng") {
297
- const result = await searchSearxng(query, options);
298
- return { ...result, failures };
389
+ const deferred = deferredResult(options.searchState, "searxng");
390
+ if (deferred) return { ...deferred, failures };
391
+ const result = await searchWithRequestCount(options.callClaims, () => guardedSearch("searxng", options.config.endpoint, options, () => searchSearxng(query, options)));
392
+ return { ...rememberProviderDeferral(result, options.searchState), failures };
393
+ }
394
+ if (config.backend === "ollama") {
395
+ const deferred = deferredResult(options.searchState, "ollama");
396
+ if (deferred) return { ...deferred, failures };
397
+ const result = await searchWithRequestCount(options.callClaims, () => guardedSearch("ollama", config.ollama.baseUrl, options, () => searchOllama(query, options)));
398
+ return { ...rememberProviderDeferral(result, options.searchState), failures };
299
399
  }
300
400
  if (config.backend === "codex") {
401
+ const deferred = deferredResult(options.searchState, "codex");
402
+ if (deferred) return { ...deferred, failures };
301
403
  if (!options.sandbox.networkAllowsUrl(options.policy, "https://chatgpt.com")) {
302
404
  return {
303
405
  ok: false,
@@ -307,15 +409,21 @@ async function searchOneQuery(query, options) {
307
409
  failures,
308
410
  };
309
411
  }
310
- const result = await options.codexSearch(query, {
311
- model: config.codex.model,
312
- signal: options.signal,
313
- });
314
- return { ...result, failures };
412
+ const result = await searchWithRequestCount(options.callClaims, () => guardedSearch("codex", "codex", options, () => options.codexSearch(query, {
413
+ model: config.codex.model, signal: options.signal, coordinator: options.coordinator,
414
+ language: options.language, timeRange: options.timeRange,
415
+ claimRequest: () => claimWebSearchRequest(options.searchState, "codex", options.callClaims),
416
+ })));
417
+ return { ...rememberProviderDeferral(result, options.searchState), failures };
315
418
  }
316
419
  if (config.backend === "keyless") {
317
420
  for (const backend of KEYLESS_BACKENDS) {
318
421
  if (options.signal?.aborted) return abortedSearch(backend, failures);
422
+ const runDeferred = deferredResult(options.searchState, backend);
423
+ if (runDeferred) {
424
+ failures.push(runDeferred);
425
+ continue;
426
+ }
319
427
  if (backendInCooldown(backend)) {
320
428
  failures.push({
321
429
  ok: false,
@@ -323,17 +431,26 @@ async function searchOneQuery(query, options) {
323
431
  message: `${backend} skipped: cooling down after rate limiting.`,
324
432
  retryable: true,
325
433
  cooldown: true,
434
+ retryAfterMs: processCooldownRemaining(backend),
435
+ retryAtMs: Date.now() + processCooldownRemaining(backend),
326
436
  });
327
437
  continue;
328
438
  }
329
- const result = await KEYLESS_RUNNERS[backend](query, options);
439
+ const result = await searchWithRequestCount(options.callClaims, () => guardedSearch(backend, backend, options, () => KEYLESS_RUNNERS[backend](query, options)));
440
+ rememberProviderDeferral(result, options.searchState);
330
441
  if (result.ok) {
331
- if (result.results.length > 0) return { ...result, failures };
442
+ if (result.results.length > 0) {
443
+ const usable = filterRelevantResults(filterByDomains(result.results, options.includeDomains, options.excludeDomains), options.relevanceQuery);
444
+ if (usable.length > 0) return { ...result, results: usable, failures };
445
+ failures.push({ backend, message: `${backend} returned no relevant results.`, relevance: true });
446
+ continue;
447
+ }
332
448
  emptySuccess ??= result;
333
449
  continue;
334
450
  }
335
451
  // The cooldown is already open — rateLimited() sets it at detection.
336
452
  failures.push(result);
453
+ if (result.code === "coordination_unavailable") return { ...result, failures };
337
454
  }
338
455
  }
339
456
  if (emptySuccess) return { ...emptySuccess, failures };
@@ -348,6 +465,39 @@ async function searchOneQuery(query, options) {
348
465
  };
349
466
  }
350
467
 
468
+ function deferredResult(searchState, backend) {
469
+ const deferred = deferredWebSearchProvider(searchState, backend);
470
+ if (!deferred) return null;
471
+ const retryAfterMs = deferred.retryAtMs === undefined ? undefined : Math.max(0, deferred.retryAtMs - Date.now());
472
+ return {
473
+ ok: false,
474
+ backend,
475
+ code: "rate_limited",
476
+ message: `${backend} is deferred for the remainder of this run.`,
477
+ retryable: true,
478
+ retryInRun: false,
479
+ cooldown: true,
480
+ rateLimited: true,
481
+ ...(retryAfterMs === undefined ? {} : { retryAfterMs, retryAtMs: deferred.retryAtMs }),
482
+ };
483
+ }
484
+
485
+ function rememberProviderDeferral(result, searchState) {
486
+ if (result?.rateLimited || result?.cooldown) {
487
+ const retryAtMs = Number.isFinite(result.retryAtMs)
488
+ ? result.retryAtMs
489
+ : Number.isFinite(result.retryAfterMs) ? Date.now() + result.retryAfterMs : undefined;
490
+ deferWebSearchProvider(searchState, result.backend, retryAtMs);
491
+ }
492
+ return result;
493
+ }
494
+
495
+ async function searchWithRequestCount(callClaims, execute) {
496
+ const before = callClaims.requests;
497
+ const result = await execute();
498
+ return { ...result, requestsConsumed: callClaims.requests - before };
499
+ }
500
+
351
501
  function abortedSearch(backend, failures = []) {
352
502
  return {
353
503
  ok: false,
@@ -358,16 +508,25 @@ function abortedSearch(backend, failures = []) {
358
508
  };
359
509
  }
360
510
 
361
- function backendInCooldown(backend) {
362
- const until = backendCooldownUntil.get(backend);
511
+ function backendInCooldown(backend, key = backend) {
512
+ const cooldownKey = processCooldownKey(backend, key);
513
+ const until = backendCooldownUntil.get(cooldownKey);
363
514
  if (until === undefined) return false;
364
515
  if (Date.now() >= until) {
365
- backendCooldownUntil.delete(backend);
516
+ backendCooldownUntil.delete(cooldownKey);
366
517
  return false;
367
518
  }
368
519
  return true;
369
520
  }
370
521
 
522
+ function processCooldownKey(backend, key) {
523
+ return `${backend}:${String(key)}`;
524
+ }
525
+
526
+ function processCooldownRemaining(backend, key = backend) {
527
+ return Math.max(0, (backendCooldownUntil.get(processCooldownKey(backend, key)) ?? Date.now()) - Date.now());
528
+ }
529
+
371
530
  /**
372
531
  * Atomically claims this backend's next send slot and reports how long the
373
532
  * caller must wait for it. Synchronous on purpose: concurrent callers each
@@ -414,6 +573,7 @@ export function __resetWebSearchThrottleForTests(overrides = {}) {
414
573
  keylessSemaphore = createCountingSemaphore(keylessThrottle.maxConcurrency);
415
574
  backendCooldownUntil.clear();
416
575
  backendNextAvailableAt.clear();
576
+ processProviderSemaphores.clear();
417
577
  }
418
578
 
419
579
  async function searchSearxng(query, options) {
@@ -433,6 +593,7 @@ async function searchSearxng(query, options) {
433
593
  body.set("time_range", options.timeRange);
434
594
  }
435
595
  try {
596
+ claimWebSearchRequest(options.searchState, "searxng", options.callClaims);
436
597
  const response = await options.fetchImpl(url, {
437
598
  method: "POST",
438
599
  headers: {
@@ -441,7 +602,7 @@ async function searchSearxng(query, options) {
441
602
  "User-Agent": "mono-agent-web/1",
442
603
  },
443
604
  body,
444
- signal: requestSignal(options.signal),
605
+ signal: options.auto ? AbortSignal.any([options.signal, AbortSignal.timeout(3000)]) : requestSignal(options.signal),
445
606
  redirect: "error",
446
607
  });
447
608
  const text = await readLimitedText(response);
@@ -450,6 +611,7 @@ async function searchSearxng(query, options) {
450
611
  ok: false,
451
612
  backend: "searxng",
452
613
  message: `SearXNG HTTP ${response.status}`,
614
+ rateLimited: response.status === 429, retryAfterMs: parseRetryAfter(response),
453
615
  retryable: response.status === 429 || response.status >= 500,
454
616
  };
455
617
  }
@@ -493,6 +655,74 @@ async function searchSearxng(query, options) {
493
655
  }
494
656
  }
495
657
 
658
+ async function searchOllama(query, options) {
659
+ const config = options.config.ollama;
660
+ if (!config) {
661
+ return { ok: false, backend: "ollama", message: "Ollama Web Search is not configured.", retryable: false };
662
+ }
663
+ const official = config.baseUrl === "https://ollama.com";
664
+ const paths = official ? ["/api/web_search"] : ["/api/experimental/web_search", "/api/web_search"];
665
+ for (let index = 0; index < paths.length; index += 1) {
666
+ const url = `${config.baseUrl}${paths[index]}`;
667
+ if (!options.sandbox.networkAllowsUrl(options.policy, url)) {
668
+ return { ok: false, backend: "ollama", message: "Network access denied by sandbox policy.", retryable: false };
669
+ }
670
+ try {
671
+ claimWebSearchRequest(options.searchState, "ollama", options.callClaims);
672
+ const response = await options.fetchImpl(url, {
673
+ method: "POST",
674
+ headers: {
675
+ Accept: "application/json",
676
+ "Content-Type": "application/json",
677
+ "User-Agent": "mono-agent-web/1",
678
+ ...(official ? { Authorization: `Bearer ${config.apiKey}` } : {}),
679
+ },
680
+ body: JSON.stringify({ query, max_results: options.maxResults }),
681
+ signal: requestSignal(options.signal),
682
+ redirect: "error",
683
+ });
684
+ const text = await readLimitedText(response);
685
+ if (!official && index === 0 && [404, 405].includes(response.status)) continue;
686
+ if (!official && index === 1 && [404, 405].includes(response.status)) {
687
+ return { ok: false, backend: "ollama", code: "endpoint_not_supported", message: "Ollama Web Search endpoints are not supported by this server.", retryable: false };
688
+ }
689
+ if (!response.ok) {
690
+ return {
691
+ ok: false,
692
+ backend: "ollama",
693
+ code: [401, 403].includes(response.status) ? "auth_failed"
694
+ : response.status === 408 ? "timeout"
695
+ : response.status === 429 ? "rate_limited"
696
+ : response.status >= 500 ? "provider_unavailable" : "provider_unavailable",
697
+ message: `Ollama Web Search HTTP ${response.status}`,
698
+ rateLimited: response.status === 429,
699
+ retryAfterMs: parseRetryAfter(response),
700
+ retryable: [408, 429].includes(response.status) || response.status >= 500,
701
+ };
702
+ }
703
+ let data;
704
+ try { data = JSON.parse(text); } catch {
705
+ return { ok: false, backend: "ollama", code: "invalid_response", message: "Ollama Web Search returned invalid JSON.", retryable: false };
706
+ }
707
+ if (!Array.isArray(data?.results)) {
708
+ return { ok: false, backend: "ollama", code: "invalid_response", message: "Ollama Web Search returned no results array.", retryable: false };
709
+ }
710
+ const results = data.results.flatMap((entry) => normalizedResult(entry, "ollama"));
711
+ if (data.results.length > 0 && results.length === 0) {
712
+ return { ok: false, backend: "ollama", code: "invalid_response", message: "Ollama Web Search returned no usable result URLs.", retryable: false };
713
+ }
714
+ return {
715
+ ok: true,
716
+ backend: "ollama",
717
+ results,
718
+ };
719
+ } catch (error) {
720
+ return ollamaFetchFailure(error, options.signal);
721
+ }
722
+ }
723
+ return { ok: false, backend: "ollama", message: "Ollama Web Search endpoint is unavailable.", retryable: false };
724
+ }
725
+
496
726
  /**
497
727
  * Shared transport for the keyless HTML engines. Both are scraped the same way
498
728
  * and both bot-gate the same way, so request shaping, throttling, challenge
@@ -518,7 +748,7 @@ async function keylessHtmlSearch(spec, options) {
518
748
  // so by the time this one is admitted a sibling may already have been
519
749
  // blocked. Without this second look the very first block still costs a full
520
750
  // round of requests against a backend we know is refusing them.
521
- if (backendInCooldown(spec.backend)) {
751
+ if (backendInCooldown(spec.backend, spec.backend)) {
522
752
  return {
523
753
  ok: false,
524
754
  backend: spec.backend,
@@ -527,6 +757,7 @@ async function keylessHtmlSearch(spec, options) {
527
757
  cooldown: true,
528
758
  };
529
759
  }
760
+ claimWebSearchRequest(options.searchState, spec.backend, options.callClaims);
530
761
  const response = await options.fetchImpl(spec.url, {
531
762
  // "manual", not "error": these engines answer a throttled query with a
532
763
  // redirect to a captcha page, and "error" collapses that into an opaque
@@ -560,7 +791,7 @@ async function keylessHtmlSearch(spec, options) {
560
791
  // once it stops asking politely. No credentials are ever sent to these
561
792
  // endpoints, so a 403 can only mean "blocked", never "unauthorized".
562
793
  if (RATE_LIMIT_STATUSES.has(response.status)) {
563
- return rateLimited(spec, `HTTP ${response.status}`);
794
+ return rateLimited(spec, `HTTP ${response.status}`, response);
564
795
  }
565
796
  if (!response.ok) {
566
797
  return {
@@ -589,7 +820,7 @@ function searchDuckDuckGo(query, options) {
589
820
  return keylessHtmlSearch({
590
821
  backend: "duckduckgo",
591
822
  label: "DuckDuckGo",
592
- url: `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`,
823
+ url: `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}${({ day: "d", month: "m", year: "y" }[options.timeRange]) ? `&df=${({ day: "d", month: "m", year: "y" }[options.timeRange])}` : ""}`,
593
824
  parse: parseDuckDuckGoResults,
594
825
  }, options);
595
826
  }
@@ -619,12 +850,16 @@ function searchStartpage(query, options) {
619
850
  // do it. The semaphore slot is released before this result reaches the caller,
620
851
  // so a queued sibling would otherwise be admitted and re-check the cooldown
621
852
  // while it was still closed, and every variant in flight would hit the wire.
622
- function rateLimited(spec, detail) {
623
- backendCooldownUntil.set(spec.backend, Date.now() + keylessThrottle.cooldownMs);
853
+ function rateLimited(spec, detail, response) {
854
+ const retryAfterMs = parseRetryAfter(response) ?? keylessThrottle.cooldownMs;
855
+ const retryAtMs = Date.now() + retryAfterMs;
856
+ backendCooldownUntil.set(processCooldownKey(spec.backend, spec.backend), retryAtMs);
624
857
  return {
625
858
  ok: false,
626
859
  backend: spec.backend,
627
860
  message: `${spec.label} rate-limited (${detail})`,
861
+ retryAfterMs,
862
+ retryAtMs,
628
863
  retryable: true,
629
864
  rateLimited: true,
630
865
  };
@@ -755,13 +990,23 @@ export function mergeRankedResults(rankedLists, limit = 10) {
755
990
 
756
991
  function normalizeSearchConfig(input) {
757
992
  const backend = input?.backend ?? "auto";
758
- if (!["auto", "searxng", "codex", "keyless"].includes(backend)) {
759
- return { error: "Web search backend must be auto, searxng, codex, or keyless." };
993
+ if (!["auto", "searxng", "ollama", "codex", "keyless"].includes(backend)) {
994
+ return { error: "Web search backend must be auto, searxng, ollama, codex, or keyless." };
995
+ }
996
+ const maxRequestsPerRun = input?.maxRequestsPerRun ?? 4;
997
+ if (!Number.isSafeInteger(maxRequestsPerRun) || maxRequestsPerRun < 1 || maxRequestsPerRun > MAX_WEB_SEARCH_REQUESTS_PER_RUN) {
998
+ return { error: `Web search maxRequestsPerRun must be an integer from 1 to ${MAX_WEB_SEARCH_REQUESTS_PER_RUN}.` };
999
+ }
1000
+ const legacyEndpoint = input?.endpoint;
1001
+ const nestedEndpoint = input?.searxng?.endpoint;
1002
+ if (legacyEndpoint && nestedEndpoint && String(legacyEndpoint).trim() !== String(nestedEndpoint).trim()) {
1003
+ return { error: "Legacy and canonical SearXNG endpoints disagree." };
760
1004
  }
761
1005
  let endpoint;
762
- if (input?.endpoint !== undefined && String(input.endpoint).trim()) {
1006
+ const endpointInput = nestedEndpoint ?? legacyEndpoint;
1007
+ if (endpointInput !== undefined && String(endpointInput).trim()) {
763
1008
  try {
764
- const parsed = new URL(String(input.endpoint));
1009
+ const parsed = new URL(String(endpointInput));
765
1010
  if (parsed.protocol !== "http:" || !isLoopbackHost(parsed.hostname) || parsed.username || parsed.password) {
766
1011
  return { error: "SearXNG endpoint must be an unauthenticated loopback http URL." };
767
1012
  }
@@ -775,15 +1020,64 @@ function normalizeSearchConfig(input) {
775
1020
  }
776
1021
  }
777
1022
  if (backend === "searxng" && !endpoint) {
778
- return { error: "SearXNG backend requires tools.web.search.endpoint." };
1023
+ return { error: "SearXNG backend requires tools.web.search.searxng.endpoint." };
779
1024
  }
1025
+ const ollama = normalizeOllamaSearchConfig(input?.ollama, backend);
1026
+ if (ollama.error) return {
1027
+ error: ollama.error,
1028
+ ...(ollama.code === undefined ? {} : { code: ollama.code }),
1029
+ };
780
1030
  const model = typeof input?.codex?.model === "string" && input.codex.model.trim()
781
1031
  ? input.codex.model.trim()
782
1032
  : "gpt-5.6-luna";
783
1033
  if (model.length > 160 || /[\u0000-\u001f\u007f]/u.test(model)) {
784
1034
  return { error: "Codex web search model must be a valid model id." };
785
1035
  }
786
- return { backend, endpoint, codex: { model } };
1036
+ return { backend, maxRequestsPerRun, endpoint, ...(ollama.value === undefined ? {} : { ollama: ollama.value }), codex: { model } };
1037
+ }
1038
+
1039
+ function normalizeOllamaSearchConfig(input, backend) {
1040
+ if (backend !== "ollama" && input === undefined) return { value: undefined };
1041
+ let parsed;
1042
+ try { parsed = new URL(input?.baseUrl || "http://127.0.0.1:11434"); }
1043
+ catch { return { error: "Ollama Web Search base URL must be a valid HTTP(S) origin." }; }
1044
+ if (!["http:", "https:"].includes(parsed.protocol)
1045
+ || parsed.username || parsed.password || parsed.search || parsed.hash
1046
+ || !["", "/"].includes(parsed.pathname)) {
1047
+ return { error: "Ollama Web Search base URL must be an HTTP(S) origin without credentials, path, query, or fragment." };
1048
+ }
1049
+ const baseUrl = parsed.origin;
1050
+ const official = baseUrl === "https://ollama.com";
1051
+ if (!official && !isPrivateOllamaOrigin(parsed) && (parsed.protocol !== "https:" || input?.trustPublicUrl !== true)) {
1052
+ return { error: "A public custom Ollama origin requires HTTPS and trustPublicUrl=true." };
1053
+ }
1054
+ if (!official && (input?.apiKey !== undefined || input?.apiKeyEnv !== undefined)) {
1055
+ return { error: "Ollama Web Search credentials are allowed only for the exact https://ollama.com origin." };
1056
+ }
1057
+ if (official && (typeof input?.apiKey !== "string" || input.apiKey.trim().length === 0)) {
1058
+ return { error: "Hosted Ollama Web Search requires a resolved API key.", code: "auth_missing" };
1059
+ }
1060
+ return { value: {
1061
+ baseUrl,
1062
+ trustPublicUrl: input?.trustPublicUrl === true,
1063
+ ...(official ? { apiKey: input.apiKey } : {}),
1064
+ ...(typeof input?.apiKeyEnv === "string" ? { apiKeyEnv: input.apiKeyEnv } : {}),
1065
+ } };
1066
+ }
1067
+
1068
+ function isPrivateOllamaOrigin(url) {
1069
+ const host = url.hostname.toLowerCase().replace(/^\[|\]$/gu, "");
1070
+ if (["localhost", "host.docker.internal", "::1"].includes(host)) return true;
1071
+ if (isIP(host) === 4) {
1072
+ const [a, b] = host.split(".").map(Number);
1073
+ return a === 10 || a === 127 || (a === 192 && b === 168)
1074
+ || (a === 172 && b >= 16 && b <= 31) || (a === 100 && b >= 64 && b <= 127);
1075
+ }
1076
+ if (isIP(host) === 6) {
1077
+ const first = Number.parseInt(host.split(":")[0] || "0", 16);
1078
+ return (first & 0xfe00) === 0xfc00 || (first & 0xffc0) === 0xfe80;
1079
+ }
1080
+ return false;
787
1081
  }
788
1082
 
789
1083
  function isLoopbackHost(hostname) {
@@ -874,7 +1168,7 @@ function sanitizeFailureMetadata(failures) {
874
1168
  const metadata = [];
875
1169
  for (const failureEntry of failures) {
876
1170
  const backend = collapseWhitespace(failureEntry?.backend).slice(0, 40) || "unknown";
877
- const code = failureEntry?.relevance
1171
+ const code = ["quota_reserved", "quota_unavailable", "coordination_unavailable", "search_budget_exhausted", "auth_failed", "invalid_response", "timeout", "provider_unavailable", "access_challenge"].includes(failureEntry?.code) ? failureEntry.code : failureEntry?.relevance
878
1172
  ? "no_relevant_results"
879
1173
  : failureEntry?.rateLimited ? "rate_limited"
880
1174
  : failureEntry?.cooldown ? "cooldown"
@@ -890,6 +1184,55 @@ function sanitizeFailureMetadata(failures) {
890
1184
  return metadata;
891
1185
  }
892
1186
 
1187
+ function providerAttemptMetadata(failures) {
1188
+ return sanitizeFailureMetadata(failures).slice(0, 12).map((entry) => {
1189
+ const source = failures.find((failureEntry) => collapseWhitespace(failureEntry?.backend).slice(0, 40) === entry.backend
1190
+ && sanitizeFailureMetadata([failureEntry])[0]?.code === entry.code);
1191
+ const retryAtMs = Number.isFinite(source?.retryAtMs)
1192
+ ? source.retryAtMs
1193
+ : Number.isFinite(source?.retryAfterMs) ? Date.now() + source.retryAfterMs : undefined;
1194
+ return {
1195
+ backend: entry.backend,
1196
+ code: entry.code,
1197
+ disposition: source?.cooldown || source?.rateLimited ? "deferred_for_run" : "advanced",
1198
+ requests: Number.isSafeInteger(source?.requestsConsumed)
1199
+ ? source.requestsConsumed
1200
+ : source?.cooldown || source?.quotaSkipped ? 0 : 1,
1201
+ ...(Number.isFinite(source?.retryAfterMs) ? { retryAfterMs: source.retryAfterMs } : {}),
1202
+ ...(retryAtMs === undefined ? {} : { retryAt: new Date(retryAtMs).toISOString() }),
1203
+ };
1204
+ });
1205
+ }
1206
+
1207
+ function earliestRetryAt(failures) {
1208
+ const values = failures.flatMap((entry) => {
1209
+ if (Number.isFinite(entry.retryAtMs)) return [entry.retryAtMs];
1210
+ if (Number.isFinite(entry.retryAfterMs)) return [Date.now() + entry.retryAfterMs];
1211
+ return [];
1212
+ }).filter((value) => value >= 0 && value <= 8_640_000_000_000_000);
1213
+ return values.length ? Math.min(...values) : undefined;
1214
+ }
1215
+
1216
+ function cooldownBackendNames(searchState) {
1217
+ const processNames = [...backendCooldownUntil.keys()].map((key) => key.split(":", 1)[0]);
1218
+ const runNames = [...(searchState?.deferredProviders?.keys?.() ?? [])];
1219
+ return [...new Set([...processNames, ...runNames])];
1220
+ }
1221
+
1222
+ function searchControlLine(searchState, requestsThisCall, providerFailures, nextAction) {
1223
+ const budget = webSearchBudgetSnapshot(searchState, requestsThisCall);
1224
+ const deferred = providerFailures.filter((entry) => entry.rateLimited || entry.cooldown).map((entry) => entry.backend);
1225
+ const deferText = deferred.length > 0
1226
+ ? ` ${[...new Set(deferred)].join(", ")} deferred for the remainder of this run.`
1227
+ : "";
1228
+ const action = nextAction === "fetch_existing_sources"
1229
+ ? "Use WebFetch on the strongest returned URLs before searching again."
1230
+ : nextAction === "refine_query"
1231
+ ? "Refine the query only for a material evidence gap."
1232
+ : "Do not retry WebSearch in this run; use available evidence and state the limitation.";
1233
+ return `[Search control: requests=${budget.requestsUsed}/${budget.maxRequestsPerRun}; remaining=${budget.requestsRemaining};${deferText} ${action}]`;
1234
+ }
1235
+
893
1236
  function searchMetadataLine({ backend, attemptedBackends, query, providerFailures }) {
894
1237
  const attempted = attemptedBackends.join(",") || "none";
895
1238
  const failures = sanitizeFailureMetadata(providerFailures)
@@ -928,7 +1271,7 @@ async function readLimitedText(response) {
928
1271
  if (!reader) {
929
1272
  const text = await response.text();
930
1273
  if (Buffer.byteLength(text, "utf8") > SEARCH_RESPONSE_MAX_BYTES) {
931
- throw new Error(`search response exceeded ${SEARCH_RESPONSE_MAX_BYTES} bytes`);
1274
+ throw Object.assign(new Error(`search response exceeded ${SEARCH_RESPONSE_MAX_BYTES} bytes`), { code: "response_too_large" });
932
1275
  }
933
1276
  return text;
934
1277
  }
@@ -940,7 +1283,7 @@ async function readLimitedText(response) {
940
1283
  bytes += next.value.byteLength;
941
1284
  if (bytes > SEARCH_RESPONSE_MAX_BYTES) {
942
1285
  try { await reader.cancel(); } catch { /* best effort */ }
943
- throw new Error(`search response exceeded ${SEARCH_RESPONSE_MAX_BYTES} bytes`);
1286
+ throw Object.assign(new Error(`search response exceeded ${SEARCH_RESPONSE_MAX_BYTES} bytes`), { code: "response_too_large" });
944
1287
  }
945
1288
  chunks.push(Buffer.from(next.value));
946
1289
  }
@@ -965,9 +1308,10 @@ const RETRYABLE_FETCH_CODES = new Set([
965
1308
 
966
1309
  function fetchFailure(backend, error, label = backend) {
967
1310
  const name = error?.name;
968
- const retryable = name === "AbortError"
1311
+ const code = error?.code ?? error?.cause?.code;
1312
+ const retryable = code !== "search_budget_exhausted" && (name === "AbortError"
969
1313
  || name === "TimeoutError"
970
- || RETRYABLE_FETCH_CODES.has(error?.code ?? error?.cause?.code);
1314
+ || RETRYABLE_FETCH_CODES.has(code));
971
1315
  const message = error?.message || String(error);
972
1316
  const cause = error?.cause?.message;
973
1317
  const detail = cause && cause !== message ? `${message} (${cause})` : message;
@@ -976,6 +1320,28 @@ function fetchFailure(backend, error, label = backend) {
976
1320
  backend,
977
1321
  message: `${label} request failed: ${detail}`,
978
1322
  retryable,
1323
+ ...(code === undefined ? {} : { code }),
1324
+ };
1325
+ }
1326
+
1327
+ function ollamaFetchFailure(error, signal) {
1328
+ const base = fetchFailure("ollama", error, "Ollama Web Search");
1329
+ const abortCode = signal?.aborted
1330
+ ? (signal.reason?.code === "deadline_exceeded" ? "deadline_exceeded" : "aborted")
1331
+ : undefined;
1332
+ const suppliedCode = error?.code ?? error?.cause?.code;
1333
+ const code = abortCode
1334
+ || (suppliedCode === "search_budget_exhausted" ? "search_budget_exhausted" : undefined)
1335
+ || (suppliedCode === "deadline_exceeded" ? "deadline_exceeded" : undefined)
1336
+ || (suppliedCode === "response_too_large" ? "response_too_large" : undefined)
1337
+ || (suppliedCode === "invalid_response" ? "invalid_response" : undefined)
1338
+ || (error?.name === "AbortError" ? "aborted" : undefined)
1339
+ || (error?.name === "TimeoutError" ? "timeout" : undefined)
1340
+ || "provider_unavailable";
1341
+ return {
1342
+ ...base,
1343
+ code,
1344
+ retryable: !["aborted", "response_too_large", "invalid_response", "search_budget_exhausted"].includes(code),
979
1345
  };
980
1346
  }
981
1347
 
@@ -998,8 +1364,103 @@ function failure(text, code, startedAt, extra = {}) {
998
1364
  };
999
1365
  }
1000
1366
 
1367
+ function searchFailure(text, code, startedAt, searchState, requestsThisCall, extra = {}) {
1368
+ const budget = webSearchBudgetSnapshot(searchState, requestsThisCall);
1369
+ const retryAfterMs = extra.retryAfterMs;
1370
+ const retryAt = extra.retryAt;
1371
+ const guidance = code === "rate_limited"
1372
+ ? `Provider retry${Number.isFinite(retryAfterMs) ? ` after ${Math.ceil(retryAfterMs / 1000)} seconds` : " time is unknown"}${retryAt ? `, at ${retryAt}` : ""}. Do not sleep or retry WebSearch to wait out this cooldown in this run. Fetch already returned URLs, or answer from available evidence and state the limitation.`
1373
+ : code === "search_budget_exhausted"
1374
+ ? "Do not retry WebSearch in this run. Fetch already returned URLs, or answer from available evidence and state the limitation."
1375
+ : "";
1376
+ const completeText = guidance
1377
+ ? `${text}\n${guidance}\nSearch requests used: ${budget.requestsUsed}/${budget.maxRequestsPerRun}.`
1378
+ : text;
1379
+ return failure(completeText, code, startedAt, {
1380
+ ...extra,
1381
+ ...budget,
1382
+ retryInRun: extra.retryInRun ?? false,
1383
+ nextAction: extra.nextAction ?? "use_available_evidence",
1384
+ bytes: Buffer.byteLength(completeText, "utf8"),
1385
+ });
1386
+ }
1387
+
1001
1388
  function clampInteger(value, min, max, fallback) {
1002
1389
  const number = Number(value);
1003
1390
  if (!Number.isFinite(number)) return fallback;
1004
1391
  return Math.max(min, Math.min(max, Math.floor(number)));
1005
1392
  }
1393
+
1394
+ async function guardedSearch(kind, key, options, execute) {
1395
+ try {
1396
+ if (options.coordinator || !["ollama", "searxng"].includes(kind)) {
1397
+ return await coordinatedWebRequest(options.coordinator, kind, key, options.signal, execute);
1398
+ }
1399
+ const cooldown = processCooldownRemaining(kind, key);
1400
+ if (cooldown > 0) return processCooldownResult(kind, cooldown);
1401
+ const scopedKey = processCooldownKey(kind, key);
1402
+ let semaphore = processProviderSemaphores.get(scopedKey);
1403
+ if (!semaphore) {
1404
+ semaphore = createCountingSemaphore(1);
1405
+ processProviderSemaphores.set(scopedKey, semaphore);
1406
+ }
1407
+ const release = await semaphore.acquire(options.signal);
1408
+ try {
1409
+ const queuedCooldown = processCooldownRemaining(kind, key);
1410
+ if (queuedCooldown > 0) return processCooldownResult(kind, queuedCooldown);
1411
+ const result = await execute();
1412
+ if (result?.rateLimited) {
1413
+ const retryAfterMs = result.retryAfterMs ?? keylessThrottle.cooldownMs;
1414
+ const retryAtMs = Date.now() + retryAfterMs;
1415
+ backendCooldownUntil.set(scopedKey, retryAtMs);
1416
+ return { ...result, retryAfterMs, retryAtMs };
1417
+ }
1418
+ return result;
1419
+ } finally {
1420
+ release();
1421
+ }
1422
+ }
1423
+ catch (error) { return webRequestFailure(error, kind, options.signal); }
1424
+ }
1425
+
1426
+ function processCooldownResult(backend, retryAfterMs) {
1427
+ return {
1428
+ ok: false,
1429
+ backend,
1430
+ code: "rate_limited",
1431
+ message: `${backend} is cooling down.`,
1432
+ retryable: true,
1433
+ cooldown: true,
1434
+ rateLimited: true,
1435
+ retryAfterMs,
1436
+ retryAtMs: Date.now() + retryAfterMs,
1437
+ };
1438
+ }
1439
+
1440
+ function parseRetryAfter(response) {
1441
+ const raw = response?.headers?.get("retry-after");
1442
+ if (!raw) return undefined;
1443
+ const now = Date.now();
1444
+ const value = raw.trim();
1445
+ let ms;
1446
+ if (/^\d+$/u.test(value)) {
1447
+ const seconds = Number(value);
1448
+ if (!Number.isSafeInteger(seconds)) return undefined;
1449
+ ms = seconds * 1000;
1450
+ } else {
1451
+ // Retry-After allows only an integer delta-seconds or an HTTP date. Do not
1452
+ // let Date.parse reinterpret malformed numeric values such as "1.5" as a
1453
+ // calendar date.
1454
+ if (/^[+-]?(?:\d+\.?\d*|\.\d+)$/u.test(value)) return undefined;
1455
+ const parsed = Date.parse(value);
1456
+ if (!Number.isFinite(parsed)) return undefined;
1457
+ ms = parsed - now;
1458
+ }
1459
+ if (!Number.isFinite(ms)) return undefined;
1460
+ return Math.min(8_640_000_000_000_000 - now, Math.max(1000, ms));
1461
+ }
1462
+
1463
+ function shortestRetry(failures) {
1464
+ const waits = failures.map((r) => r.retryAfterMs).filter((n) => Number.isFinite(n) && n > 0);
1465
+ return waits.length ? Math.min(...waits) : undefined;
1466
+ }