@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.
- package/ARCHITECTURE.md +50 -11
- package/MIGRATION.md +30 -7
- package/README.md +219 -35
- package/package.json +9 -4
- package/src/agent/tool-bloat.js +145 -9
- package/src/agent/tools/agent-tool.js +104 -5
- package/src/agent/tools/bash.js +10 -2
- package/src/agent/tools/codex-subscription-search.js +122 -28
- package/src/agent/tools/exec.js +10 -2
- package/src/agent/tools/monitor.js +11 -2
- package/src/agent/tools/pi-bridge.js +33 -14
- package/src/agent/tools/shared/monitors.js +22 -3
- package/src/agent/tools/shared/path-resolver.js +25 -6
- package/src/agent/tools/shared/process-jobs.js +6 -1
- package/src/agent/tools/shared/process-runner.js +3 -1
- package/src/agent/tools/shared/tool-context.js +8 -0
- package/src/agent/tools/web-access-interstitial.js +70 -0
- package/src/agent/tools/web-browser-render.js +83 -58
- package/src/agent/tools/web-controller.js +112 -21
- package/src/agent/tools/web-document-extractor.js +379 -0
- package/src/agent/tools/web-fetch.js +271 -243
- package/src/agent/tools/web-request.js +65 -0
- package/src/agent/tools/web-search-output.js +165 -0
- package/src/agent/tools/web-search-state.js +75 -0
- package/src/agent/tools/web-search.js +532 -71
- package/src/ai/failure.js +3 -3
- package/src/ai/index.js +1 -0
- package/src/ai/observer.js +8 -0
- package/src/ai/pi-interop.js +156 -0
- package/src/ai/provider-check.js +131 -0
- package/src/ai/providers/pi-native/compaction-driver.js +45 -21
- package/src/ai/providers/pi-native/compaction-summary.js +140 -0
- package/src/ai/providers/pi-native/harness-adapter.js +40 -2
- package/src/ai/providers/pi-native/prompt-cache-diagnostics.js +103 -0
- package/src/ai/providers/pi-native/provider-attribution.js +102 -0
- package/src/ai/providers/pi-native/result-builder.js +28 -4
- package/src/ai/providers/pi-native/session-lifecycle.js +167 -24
- package/src/ai/providers/pi-native/stream-subscriber.js +30 -2
- package/src/ai/providers/pi-native/terminal-recovery.js +40 -0
- package/src/ai/providers/pi-native/turn-runner.js +245 -13
- package/src/ai/providers/pi-native.js +159 -40
- package/src/ai/runtime/live-input-events.js +250 -54
- package/src/ai/runtime/router.js +30 -11
- package/src/ai/tool-lifecycle.js +32 -18
- package/src/ai/types.js +26 -5
- package/src/runtime.js +24 -5
- package/types/agent/tool-bloat.d.ts +1 -1
- package/types/agent/tools/agent-tool.d.ts +4 -1
- package/types/agent/tools/bash.d.ts +5 -3
- package/types/agent/tools/codex-subscription-search.d.ts +6 -2
- package/types/agent/tools/exec.d.ts +5 -3
- package/types/agent/tools/monitor.d.ts +5 -2
- package/types/agent/tools/pi-bridge.d.ts +6 -4
- package/types/agent/tools/shared/monitors.d.ts +17 -2
- package/types/agent/tools/shared/process-jobs.d.ts +5 -1
- package/types/agent/tools/shared/process-runner.d.ts +3 -2
- package/types/agent/tools/shared/tool-context.d.ts +2 -0
- package/types/agent/tools/web-access-interstitial.d.ts +23 -0
- package/types/agent/tools/web-browser-render.d.ts +4 -1
- package/types/agent/tools/web-controller.d.ts +4 -2
- package/types/agent/tools/web-document-extractor.d.ts +27 -0
- package/types/agent/tools/web-fetch.d.ts +19 -24
- package/types/agent/tools/web-request.d.ts +20 -0
- package/types/agent/tools/web-search-output.d.ts +31 -0
- package/types/agent/tools/web-search-state.d.ts +21 -0
- package/types/agent/tools/web-search.d.ts +10 -45
- package/types/ai/index.d.ts +1 -0
- package/types/ai/observer.d.ts +6 -0
- package/types/ai/pi-interop.d.ts +61 -0
- package/types/ai/provider-check.d.ts +53 -0
- package/types/ai/providers/pi-native/compaction-driver.d.ts +2 -1
- package/types/ai/providers/pi-native/compaction-summary.d.ts +19 -0
- package/types/ai/providers/pi-native/harness-adapter.d.ts +3 -1
- package/types/ai/providers/pi-native/prompt-cache-diagnostics.d.ts +3 -0
- package/types/ai/providers/pi-native/provider-attribution.d.ts +26 -0
- package/types/ai/providers/pi-native/result-builder.d.ts +11 -1
- package/types/ai/providers/pi-native/session-lifecycle.d.ts +23 -5
- package/types/ai/providers/pi-native/terminal-recovery.d.ts +2 -0
- package/types/ai/providers/pi-native/turn-runner.d.ts +36 -5
- package/types/ai/runtime/live-input-events.d.ts +32 -8
- package/types/ai/tool-lifecycle.d.ts +4 -3
- 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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
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
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
267
|
-
resultCount:
|
|
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:
|
|
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
|
|
298
|
-
return { ...
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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)
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1006
|
+
const endpointInput = nestedEndpoint ?? legacyEndpoint;
|
|
1007
|
+
if (endpointInput !== undefined && String(endpointInput).trim()) {
|
|
763
1008
|
try {
|
|
764
|
-
const parsed = new URL(String(
|
|
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
|
|
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(
|
|
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
|
+
}
|