@mono-agent/agent-runtime 0.18.0 → 0.18.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MIGRATION.md +49 -1
- package/README.md +73 -10
- package/package.json +1 -1
- package/src/agent/tools/agent-tool.js +16 -4
- package/src/agent/tools/web-controller.js +97 -7
- package/src/agent/tools/web-search.js +296 -60
- package/src/ai/providers/acp-client.js +38 -13
- package/src/ai/providers/acp-session-tokens.js +198 -45
- package/src/ai/providers/acp-transport.js +97 -0
- package/src/ai/providers/acp.js +22 -6
- package/src/ai/providers/claude-cli.js +77 -22
- package/src/ai/providers/claude-sdk.js +54 -11
- package/src/ai/providers/claude-subagent-activity.js +719 -0
- package/src/ai/providers/codex-app.js +1039 -105
- package/src/ai/runtime/router.js +42 -0
- package/src/ai/types.js +101 -11
- package/src/runtime.js +1 -0
- package/types/agent/tools/web-controller.d.ts +5 -1
- package/types/agent/tools/web-search.d.ts +13 -0
- package/types/ai/providers/acp-client.d.ts +4 -0
- package/types/ai/providers/acp-session-tokens.d.ts +16 -9
- package/types/ai/providers/acp-transport.d.ts +13 -0
- package/types/ai/providers/claude-subagent-activity.d.ts +53 -0
- package/types/ai/runtime/router.d.ts +9 -0
- package/types/ai/types.d.ts +243 -22
|
@@ -3,11 +3,49 @@
|
|
|
3
3
|
import { parseHTML } from "linkedom";
|
|
4
4
|
import { passthroughSandbox } from "../sandbox-seam.js";
|
|
5
5
|
import { readToolRuntime } from "./shared/runtime-context.js";
|
|
6
|
+
import { createCountingSemaphore } from "./shared/semaphore.js";
|
|
6
7
|
import { resolveSandboxPolicy } from "./shared/tool-context.js";
|
|
7
8
|
|
|
8
9
|
const SEARCH_TIMEOUT_MS = 15_000;
|
|
9
10
|
const SEARCH_RESPONSE_MAX_BYTES = 2 * 1024 * 1024;
|
|
10
11
|
const RRF_K = 60;
|
|
12
|
+
|
|
13
|
+
// Keyless engines rate-limit by source IP, and one agent can hammer them from
|
|
14
|
+
// many directions at once: a single WebSearch fans out to up to four queries,
|
|
15
|
+
// and every subagent runs its own web controller, so nothing below this module
|
|
16
|
+
// sees the aggregate. Measured against html.duckduckgo.com: ~8 requests in ~4s
|
|
17
|
+
// served normally, ~12 in ~5s tripped an `HTTP 202` anomaly page that persisted
|
|
18
|
+
// for over two minutes. These bounds therefore live at MODULE scope so they
|
|
19
|
+
// apply process-wide (one process is one agent instance), not per run.
|
|
20
|
+
const KEYLESS_DEFAULT_THROTTLE = {
|
|
21
|
+
// Simultaneous in-flight keyless requests across the whole process.
|
|
22
|
+
maxConcurrency: 3,
|
|
23
|
+
// Minimum gap between two requests to the SAME keyless backend, i.e. ~0.67/s
|
|
24
|
+
// against roughly 2.4/s measured to trip a ban. The asymmetry is deliberate:
|
|
25
|
+
// being too slow costs a few seconds on a multi-query search, while being too
|
|
26
|
+
// fast costs a five-minute outage that escalates from a 202 challenge to an
|
|
27
|
+
// outright 403. Only the fan-out pays it — a single-query search never waits.
|
|
28
|
+
minSpacingMs: 1_500,
|
|
29
|
+
// How long a backend stays skipped after it signals rate limiting. Observed
|
|
30
|
+
// blocks outlasted several minutes, so this is deliberately longer.
|
|
31
|
+
cooldownMs: 5 * 60_000,
|
|
32
|
+
};
|
|
33
|
+
// Ordered keyless fallback chain. DuckDuckGo first: it yields cleaner titles and
|
|
34
|
+
// snippets, but it is also the one that bans, which is precisely why Startpage
|
|
35
|
+
// behind it has to actually work.
|
|
36
|
+
const KEYLESS_BACKENDS = ["duckduckgo", "startpage"];
|
|
37
|
+
// Markers that identify an interstitial/bot-gate body served with a 2xx status.
|
|
38
|
+
const CHALLENGE_BODY_RE = /anomaly|unusual traffic|captcha|are you a robot|challenge-(?:platform|form)/iu;
|
|
39
|
+
// Statuses these engines use to say "you are sending too much", all of which
|
|
40
|
+
// must put the backend into cooldown rather than be retried next search.
|
|
41
|
+
const RATE_LIMIT_STATUSES = new Set([202, 403, 429]);
|
|
42
|
+
|
|
43
|
+
let keylessThrottle = { ...KEYLESS_DEFAULT_THROTTLE };
|
|
44
|
+
let keylessSemaphore = createCountingSemaphore(keylessThrottle.maxConcurrency);
|
|
45
|
+
/** @type {Map<string, number>} Backend -> epoch ms until which it is skipped. */
|
|
46
|
+
const backendCooldownUntil = new Map();
|
|
47
|
+
/** @type {Map<string, number>} Backend -> epoch ms its next request may start. */
|
|
48
|
+
const backendNextAvailableAt = new Map();
|
|
11
49
|
const TRACKING_PARAMETERS = new Set([
|
|
12
50
|
"dclid",
|
|
13
51
|
"fbclid",
|
|
@@ -96,11 +134,14 @@ export async function performWebSearch(
|
|
|
96
134
|
);
|
|
97
135
|
};
|
|
98
136
|
const recordResult = (result) => {
|
|
137
|
+
// Chain failures are reported even when a later backend rescued the query,
|
|
138
|
+
// so a silent degradation to the fallback is still visible in the outcome.
|
|
139
|
+
if (result.failures?.length) providerFailures.push(...result.failures);
|
|
99
140
|
if (result.ok) {
|
|
100
141
|
anyProviderSucceeded = true;
|
|
101
142
|
providersUsed.add(result.backend);
|
|
102
143
|
rankedLists.push(filterByDomains(result.results, includeDomains, excludeDomains));
|
|
103
|
-
} else {
|
|
144
|
+
} else if (!result.failures?.length) {
|
|
104
145
|
providerFailures.push(result);
|
|
105
146
|
}
|
|
106
147
|
};
|
|
@@ -124,16 +165,22 @@ export async function performWebSearch(
|
|
|
124
165
|
}
|
|
125
166
|
|
|
126
167
|
if (!anyProviderSucceeded) {
|
|
127
|
-
|
|
168
|
+
// Four query variants against two backends produce the same handful of
|
|
169
|
+
// messages over and over; dedupe so the reason stays readable.
|
|
170
|
+
const reason = [...new Set(providerFailures.map((entry) => entry.message).filter(Boolean))].join("; ")
|
|
128
171
|
|| "No search backend was available.";
|
|
129
172
|
const networkDenied = providerFailures.length > 0
|
|
130
173
|
&& providerFailures.every((entry) => entry.message === "Network access denied by sandbox policy.");
|
|
174
|
+
const throttled = providerFailures.some((entry) => entry.rateLimited || entry.cooldown);
|
|
131
175
|
return failure(networkDenied
|
|
132
176
|
? "Error: Network access denied by sandbox policy."
|
|
133
|
-
: `Error: WebSearch failed: ${reason}`,
|
|
177
|
+
: `Error: WebSearch failed: ${reason}`,
|
|
178
|
+
networkDenied ? "network_denied" : (throttled ? "rate_limited" : "backend_unavailable"), startedAt, {
|
|
134
179
|
attempts,
|
|
135
180
|
backend: config.backend,
|
|
136
181
|
retryable: providerFailures.some((entry) => entry.retryable),
|
|
182
|
+
rateLimited: throttled,
|
|
183
|
+
cooldownBackends: [...backendCooldownUntil.keys()],
|
|
137
184
|
});
|
|
138
185
|
}
|
|
139
186
|
|
|
@@ -163,48 +210,137 @@ export async function performWebSearch(
|
|
|
163
210
|
truncated: false,
|
|
164
211
|
resultCount: merged.length,
|
|
165
212
|
providerFailureCount: providerFailures.length,
|
|
213
|
+
rateLimited: providerFailures.some((entry) => entry.rateLimited || entry.cooldown),
|
|
214
|
+
cooldownBackends: [...backendCooldownUntil.keys()],
|
|
166
215
|
},
|
|
167
216
|
error: false,
|
|
168
217
|
};
|
|
169
218
|
}
|
|
170
219
|
|
|
220
|
+
const KEYLESS_RUNNERS = {
|
|
221
|
+
duckduckgo: searchDuckDuckGo,
|
|
222
|
+
startpage: searchStartpage,
|
|
223
|
+
};
|
|
224
|
+
|
|
171
225
|
async function searchOneQuery(query, options) {
|
|
172
226
|
const { config } = options;
|
|
227
|
+
// Every failure along the chain is kept and carried out on the winning result
|
|
228
|
+
// too. Reporting only the last one is what made a DuckDuckGo ban surface as
|
|
229
|
+
// "startpage request failed: fetch failed" and sent diagnosis the wrong way.
|
|
173
230
|
const failures = [];
|
|
174
|
-
if (options.signal?.aborted) return abortedSearch(config.backend);
|
|
231
|
+
if (options.signal?.aborted) return abortedSearch(config.backend, failures);
|
|
175
232
|
if (config.backend === "searxng" || (config.backend === "auto" && config.endpoint)) {
|
|
176
233
|
const result = await searchSearxng(query, options);
|
|
177
|
-
if (result.ok || config.backend === "searxng") return result;
|
|
234
|
+
if (result.ok || config.backend === "searxng") return { ...result, failures };
|
|
178
235
|
failures.push(result);
|
|
179
|
-
if (options.signal?.aborted) return abortedSearch(result.backend);
|
|
236
|
+
if (options.signal?.aborted) return abortedSearch(result.backend, failures);
|
|
180
237
|
}
|
|
181
238
|
if (config.backend === "keyless" || config.backend === "auto") {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
239
|
+
// A genuinely empty 200 is a real answer, not a transport failure — but it
|
|
240
|
+
// is only worth returning once every backend has had its turn.
|
|
241
|
+
let emptySuccess = null;
|
|
242
|
+
for (const backend of KEYLESS_BACKENDS) {
|
|
243
|
+
if (options.signal?.aborted) return abortedSearch(backend, failures);
|
|
244
|
+
if (backendInCooldown(backend)) {
|
|
245
|
+
failures.push({
|
|
246
|
+
ok: false,
|
|
247
|
+
backend,
|
|
248
|
+
message: `${backend} skipped: cooling down after rate limiting.`,
|
|
249
|
+
retryable: true,
|
|
250
|
+
cooldown: true,
|
|
251
|
+
});
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const result = await KEYLESS_RUNNERS[backend](query, options);
|
|
255
|
+
if (result.ok) {
|
|
256
|
+
if (result.results.length > 0) return { ...result, failures };
|
|
257
|
+
emptySuccess = result;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
// The cooldown is already open — rateLimited() sets it at detection.
|
|
261
|
+
failures.push(result);
|
|
262
|
+
}
|
|
263
|
+
if (emptySuccess) return { ...emptySuccess, failures };
|
|
190
264
|
}
|
|
191
|
-
return
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
265
|
+
return {
|
|
266
|
+
...(failures[failures.length - 1] || {
|
|
267
|
+
ok: false,
|
|
268
|
+
backend: config.backend,
|
|
269
|
+
message: "No configured search backend.",
|
|
270
|
+
retryable: false,
|
|
271
|
+
}),
|
|
272
|
+
failures,
|
|
196
273
|
};
|
|
197
274
|
}
|
|
198
275
|
|
|
199
|
-
function abortedSearch(backend) {
|
|
276
|
+
function abortedSearch(backend, failures = []) {
|
|
200
277
|
return {
|
|
201
278
|
ok: false,
|
|
202
279
|
backend,
|
|
203
280
|
message: "WebSearch was aborted.",
|
|
204
281
|
retryable: false,
|
|
282
|
+
failures,
|
|
205
283
|
};
|
|
206
284
|
}
|
|
207
285
|
|
|
286
|
+
function backendInCooldown(backend) {
|
|
287
|
+
const until = backendCooldownUntil.get(backend);
|
|
288
|
+
if (until === undefined) return false;
|
|
289
|
+
if (Date.now() >= until) {
|
|
290
|
+
backendCooldownUntil.delete(backend);
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
return true;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Atomically claims this backend's next send slot and reports how long the
|
|
298
|
+
* caller must wait for it. Synchronous on purpose: concurrent callers each
|
|
299
|
+
* reserve a distinct slot instead of all reading the same "last sent at".
|
|
300
|
+
*
|
|
301
|
+
* @returns {number} Milliseconds to wait before sending.
|
|
302
|
+
*/
|
|
303
|
+
function reserveKeylessSlot(backend) {
|
|
304
|
+
const now = Date.now();
|
|
305
|
+
const earliest = Math.max(now, backendNextAvailableAt.get(backend) ?? 0);
|
|
306
|
+
backendNextAvailableAt.set(backend, earliest + keylessThrottle.minSpacingMs);
|
|
307
|
+
return earliest - now;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Deliberately NOT unref'd: this delay is part of an in-flight search the
|
|
311
|
+
// caller is awaiting. An unref'd timer lets the event loop drain while the
|
|
312
|
+
// search is still pending, and a one-shot CLI turn then exits mid-query.
|
|
313
|
+
// Cancellation is the signal's job, not the timer's.
|
|
314
|
+
function sleep(ms, signal) {
|
|
315
|
+
if (!ms) return Promise.resolve();
|
|
316
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
317
|
+
const onAbort = () => {
|
|
318
|
+
clearTimeout(timer);
|
|
319
|
+
// Named so fetchFailure classifies it alongside every other abort.
|
|
320
|
+
rejectPromise(Object.assign(new Error("WebSearch was aborted."), { name: "AbortError" }));
|
|
321
|
+
};
|
|
322
|
+
const timer = setTimeout(() => {
|
|
323
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
324
|
+
resolvePromise();
|
|
325
|
+
}, ms);
|
|
326
|
+
if (signal?.aborted) onAbort();
|
|
327
|
+
else signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Test hook: restores the shipped throttle values and clears cooldown/spacing
|
|
333
|
+
* state. Module-scoped state would otherwise leak between test cases.
|
|
334
|
+
*
|
|
335
|
+
* @param {{maxConcurrency?: number, minSpacingMs?: number, cooldownMs?: number}} [overrides]
|
|
336
|
+
*/
|
|
337
|
+
export function __resetWebSearchThrottleForTests(overrides = {}) {
|
|
338
|
+
keylessThrottle = { ...KEYLESS_DEFAULT_THROTTLE, ...overrides };
|
|
339
|
+
keylessSemaphore = createCountingSemaphore(keylessThrottle.maxConcurrency);
|
|
340
|
+
backendCooldownUntil.clear();
|
|
341
|
+
backendNextAvailableAt.clear();
|
|
342
|
+
}
|
|
343
|
+
|
|
208
344
|
async function searchSearxng(query, options) {
|
|
209
345
|
const endpoint = options.config.endpoint;
|
|
210
346
|
if (!endpoint) {
|
|
@@ -255,66 +391,143 @@ async function searchSearxng(query, options) {
|
|
|
255
391
|
}
|
|
256
392
|
}
|
|
257
393
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
394
|
+
/**
|
|
395
|
+
* Shared transport for the keyless HTML engines. Both are scraped the same way
|
|
396
|
+
* and both bot-gate the same way, so request shaping, throttling, challenge
|
|
397
|
+
* detection, and error classification live here exactly once.
|
|
398
|
+
*
|
|
399
|
+
* @param {{backend: string, label: string, url: string, init?: RequestInit, parse: (html: string) => any[]}} spec
|
|
400
|
+
*/
|
|
401
|
+
async function keylessHtmlSearch(spec, options) {
|
|
402
|
+
if (!options.sandbox.networkAllowsUrl(options.policy, spec.url)) {
|
|
403
|
+
return { ok: false, backend: spec.backend, message: "Network access denied by sandbox policy.", retryable: false };
|
|
262
404
|
}
|
|
405
|
+
let release;
|
|
263
406
|
try {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
407
|
+
release = await keylessSemaphore.acquire(options.signal);
|
|
408
|
+
} catch {
|
|
409
|
+
// Queued behind the concurrency bound when the turn was cancelled.
|
|
410
|
+
return { ok: false, backend: spec.backend, message: "WebSearch was aborted.", retryable: false };
|
|
411
|
+
}
|
|
412
|
+
try {
|
|
413
|
+
const waitMs = reserveKeylessSlot(spec.backend);
|
|
414
|
+
if (waitMs > 0) await sleep(waitMs, options.signal);
|
|
415
|
+
// Query variants all clear the cooldown check together and then queue here,
|
|
416
|
+
// so by the time this one is admitted a sibling may already have been
|
|
417
|
+
// blocked. Without this second look the very first block still costs a full
|
|
418
|
+
// round of requests against a backend we know is refusing them.
|
|
419
|
+
if (backendInCooldown(spec.backend)) {
|
|
275
420
|
return {
|
|
276
421
|
ok: false,
|
|
277
|
-
backend:
|
|
278
|
-
message:
|
|
279
|
-
retryable:
|
|
422
|
+
backend: spec.backend,
|
|
423
|
+
message: `${spec.backend} skipped: cooling down after rate limiting.`,
|
|
424
|
+
retryable: true,
|
|
425
|
+
cooldown: true,
|
|
280
426
|
};
|
|
281
427
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
}
|
|
293
|
-
try {
|
|
294
|
-
const response = await options.fetchImpl(url, {
|
|
428
|
+
const response = await options.fetchImpl(spec.url, {
|
|
429
|
+
// "manual", not "error": these engines answer a throttled query with a
|
|
430
|
+
// redirect to a captcha page, and "error" collapses that into an opaque
|
|
431
|
+
// `TypeError: fetch failed` with no way to tell it from a real outage.
|
|
432
|
+
// The redirect is still never followed, so the open-redirect guard holds.
|
|
433
|
+
redirect: "manual",
|
|
434
|
+
...(spec.init || {}),
|
|
435
|
+
// Headers are merged last on purpose: spreading `spec.init` afterwards
|
|
436
|
+
// would replace the whole headers object with the backend's few extra
|
|
437
|
+
// entries, and a Startpage POST without a User-Agent gets bot-gated.
|
|
295
438
|
headers: {
|
|
296
439
|
Accept: "text/html,application/xhtml+xml",
|
|
297
440
|
"Accept-Language": options.language || "en-US,en;q=0.8",
|
|
298
441
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) mono-agent-web/1",
|
|
442
|
+
...(spec.init?.headers || {}),
|
|
299
443
|
},
|
|
300
444
|
signal: requestSignal(options.signal),
|
|
301
|
-
redirect: "error",
|
|
302
445
|
});
|
|
303
446
|
const html = await readLimitedText(response);
|
|
447
|
+
// Startpage answers a blocked source IP with `303 -> /sp/captcha-block`.
|
|
448
|
+
// The destination is the block itself, never results, so following it only
|
|
449
|
+
// costs a round trip and still parses to nothing.
|
|
450
|
+
if (response.status >= 300 && response.status < 400) {
|
|
451
|
+
const location = response.headers.get("location") || "";
|
|
452
|
+
return rateLimited(spec, /captcha|blocked|sorry|challenge/iu.test(location)
|
|
453
|
+
? "captcha redirect"
|
|
454
|
+
: `HTTP ${response.status} redirect`);
|
|
455
|
+
}
|
|
456
|
+
// 202 is DuckDuckGo's soft challenge (and is `ok`, so status alone would let
|
|
457
|
+
// an interstitial through as an empty success); 403 is what it escalates to
|
|
458
|
+
// once it stops asking politely. No credentials are ever sent to these
|
|
459
|
+
// endpoints, so a 403 can only mean "blocked", never "unauthorized".
|
|
460
|
+
if (RATE_LIMIT_STATUSES.has(response.status)) {
|
|
461
|
+
return rateLimited(spec, `HTTP ${response.status}`);
|
|
462
|
+
}
|
|
304
463
|
if (!response.ok) {
|
|
305
464
|
return {
|
|
306
465
|
ok: false,
|
|
307
|
-
backend:
|
|
308
|
-
message:
|
|
309
|
-
retryable: response.status
|
|
466
|
+
backend: spec.backend,
|
|
467
|
+
message: `${spec.label} HTTP ${response.status}`,
|
|
468
|
+
retryable: response.status >= 500,
|
|
310
469
|
};
|
|
311
470
|
}
|
|
312
|
-
|
|
471
|
+
const results = spec.parse(html);
|
|
472
|
+
// A 200 that parses to nothing is ambiguous: either a genuinely empty
|
|
473
|
+
// result set or a bot gate. Only the body markers tell them apart, and
|
|
474
|
+
// conflating them is what made a ban look like "No results."
|
|
475
|
+
if (results.length === 0 && CHALLENGE_BODY_RE.test(html)) {
|
|
476
|
+
return rateLimited(spec, "interstitial challenge page");
|
|
477
|
+
}
|
|
478
|
+
return { ok: true, backend: spec.backend, results };
|
|
313
479
|
} catch (error) {
|
|
314
|
-
return fetchFailure(
|
|
480
|
+
return fetchFailure(spec.backend, error, spec.label);
|
|
481
|
+
} finally {
|
|
482
|
+
release();
|
|
315
483
|
}
|
|
316
484
|
}
|
|
317
485
|
|
|
486
|
+
function searchDuckDuckGo(query, options) {
|
|
487
|
+
return keylessHtmlSearch({
|
|
488
|
+
backend: "duckduckgo",
|
|
489
|
+
label: "DuckDuckGo",
|
|
490
|
+
url: `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`,
|
|
491
|
+
parse: parseDuckDuckGoResults,
|
|
492
|
+
}, options);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function searchStartpage(query, options) {
|
|
496
|
+
// Startpage serves its results from a form POST. The old query-string GET was
|
|
497
|
+
// answered with a 3xx, which `redirect: "error"` turned into a bare
|
|
498
|
+
// `TypeError: fetch failed` — so this backend never once returned a result,
|
|
499
|
+
// and its useless error was the only thing the operator ever saw.
|
|
500
|
+
const body = new URLSearchParams({ query, cat: "web" });
|
|
501
|
+
const withDate = { day: "d", month: "m", year: "y" }[options.timeRange];
|
|
502
|
+
if (withDate) body.set("with_date", withDate);
|
|
503
|
+
return keylessHtmlSearch({
|
|
504
|
+
backend: "startpage",
|
|
505
|
+
label: "Startpage",
|
|
506
|
+
url: "https://www.startpage.com/sp/search",
|
|
507
|
+
init: {
|
|
508
|
+
method: "POST",
|
|
509
|
+
body,
|
|
510
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8" },
|
|
511
|
+
},
|
|
512
|
+
parse: parseStartpageResults,
|
|
513
|
+
}, options);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Opens the cooldown at the moment of detection rather than letting the caller
|
|
517
|
+
// do it. The semaphore slot is released before this result reaches the caller,
|
|
518
|
+
// so a queued sibling would otherwise be admitted and re-check the cooldown
|
|
519
|
+
// while it was still closed, and every variant in flight would hit the wire.
|
|
520
|
+
function rateLimited(spec, detail) {
|
|
521
|
+
backendCooldownUntil.set(spec.backend, Date.now() + keylessThrottle.cooldownMs);
|
|
522
|
+
return {
|
|
523
|
+
ok: false,
|
|
524
|
+
backend: spec.backend,
|
|
525
|
+
message: `${spec.label} rate-limited (${detail})`,
|
|
526
|
+
retryable: true,
|
|
527
|
+
rateLimited: true,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
318
531
|
export function parseDuckDuckGoResults(html) {
|
|
319
532
|
const { document } = parseHTML(String(html || ""));
|
|
320
533
|
const rows = [...document.querySelectorAll(".result")];
|
|
@@ -334,6 +547,10 @@ export function parseDuckDuckGoResults(html) {
|
|
|
334
547
|
|
|
335
548
|
export function parseStartpageResults(html) {
|
|
336
549
|
const { document } = parseHTML(String(html || ""));
|
|
550
|
+
// Startpage inlines emotion CSS in <style> tags nested inside the result
|
|
551
|
+
// anchors, and textContent happily returns the stylesheet as part of the
|
|
552
|
+
// title (".css-i3irj7{line-height:18px;...}Best time to visit Japan").
|
|
553
|
+
for (const node of document.querySelectorAll("style, script")) node.remove();
|
|
337
554
|
const selectors = [".w-gl__result", ".result", "article"];
|
|
338
555
|
const rows = selectors.flatMap((selector) => [...document.querySelectorAll(selector)]);
|
|
339
556
|
const seen = new Set();
|
|
@@ -541,15 +758,34 @@ async function readLimitedText(response) {
|
|
|
541
758
|
return Buffer.concat(chunks).toString("utf8");
|
|
542
759
|
}
|
|
543
760
|
|
|
544
|
-
|
|
761
|
+
// undici reports transport problems as a bare `TypeError: fetch failed` and
|
|
762
|
+
// keeps the real reason on `error.cause` — dropping it is what left an
|
|
763
|
+
// "unexpected redirect" looking like an unexplained network fault.
|
|
764
|
+
const RETRYABLE_FETCH_CODES = new Set([
|
|
765
|
+
"ECONNRESET",
|
|
766
|
+
"ECONNREFUSED",
|
|
767
|
+
"ETIMEDOUT",
|
|
768
|
+
"EAI_AGAIN",
|
|
769
|
+
"ENOTFOUND",
|
|
770
|
+
"ENETUNREACH",
|
|
771
|
+
"ENETDOWN",
|
|
772
|
+
"EPIPE",
|
|
773
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
774
|
+
"UND_ERR_SOCKET",
|
|
775
|
+
]);
|
|
776
|
+
|
|
777
|
+
function fetchFailure(backend, error, label = backend) {
|
|
545
778
|
const name = error?.name;
|
|
546
779
|
const retryable = name === "AbortError"
|
|
547
780
|
|| name === "TimeoutError"
|
|
548
|
-
||
|
|
781
|
+
|| RETRYABLE_FETCH_CODES.has(error?.code ?? error?.cause?.code);
|
|
782
|
+
const message = error?.message || String(error);
|
|
783
|
+
const cause = error?.cause?.message;
|
|
784
|
+
const detail = cause && cause !== message ? `${message} (${cause})` : message;
|
|
549
785
|
return {
|
|
550
786
|
ok: false,
|
|
551
787
|
backend,
|
|
552
|
-
message: `${
|
|
788
|
+
message: `${label} request failed: ${detail}`,
|
|
553
789
|
retryable,
|
|
554
790
|
};
|
|
555
791
|
}
|
|
@@ -8,6 +8,7 @@ import { passthroughSandbox } from "../../agent/sandbox-seam.js";
|
|
|
8
8
|
import {
|
|
9
9
|
ACP_DEFAULT_MAX_LINE_BYTES,
|
|
10
10
|
AcpTransportError,
|
|
11
|
+
connectWithSafeAcpSdkDiagnostics,
|
|
11
12
|
createBoundedAcpStdioStream,
|
|
12
13
|
normalizeAcpMaxLineBytes,
|
|
13
14
|
} from "./acp-transport.js";
|
|
@@ -20,10 +21,12 @@ import {
|
|
|
20
21
|
encodeAcpSessionCursor,
|
|
21
22
|
validateAcpProfileId,
|
|
22
23
|
validateAcpProviderSessionId,
|
|
24
|
+
validateAcpSessionTokenKey,
|
|
23
25
|
} from "./acp-session-tokens.js";
|
|
24
26
|
|
|
25
27
|
const OWNERS = new Set(["client", "agent"]);
|
|
26
28
|
const RESUME_STRATEGIES = new Set(["auto", "load", "resume"]);
|
|
29
|
+
const TOKEN_FREE_OPERATIONS = new Set(["probe", "authenticate", "logout"]);
|
|
27
30
|
const DEFAULT_PROCESS_POLICY = Object.freeze({
|
|
28
31
|
startupTimeoutMs: 10_000,
|
|
29
32
|
requestTimeoutMs: 60_000,
|
|
@@ -135,6 +138,7 @@ const DEFAULT_PROCESS_POLICY = Object.freeze({
|
|
|
135
138
|
* @property {string} [cwd]
|
|
136
139
|
* @property {AbortSignal} [signal]
|
|
137
140
|
* @property {Record<string, unknown>} [context]
|
|
141
|
+
* @property {Uint8Array} [acpSessionTokenKey] Host-owned 32-byte key required by operations that emit or consume opaque session handles.
|
|
138
142
|
*/
|
|
139
143
|
|
|
140
144
|
export {
|
|
@@ -527,6 +531,9 @@ export async function connectAcpProfile(profileId, options) {
|
|
|
527
531
|
validateAcpProfileId(profileId);
|
|
528
532
|
throwIfAborted(options?.signal);
|
|
529
533
|
const operation = options?.operation || "connect";
|
|
534
|
+
const sessionTokenKey = TOKEN_FREE_OPERATIONS.has(operation)
|
|
535
|
+
? undefined
|
|
536
|
+
: validateAcpSessionTokenKey(options?.acpSessionTokenKey);
|
|
530
537
|
const descriptor = await resolveProfile(profileId, { ...options, operation });
|
|
531
538
|
throwIfAborted(options?.signal);
|
|
532
539
|
const capabilities = clientCapabilities(descriptor);
|
|
@@ -575,7 +582,13 @@ export async function connectAcpProfile(profileId, options) {
|
|
|
575
582
|
const safeCallbackContext = (context, rawSessionId) => ({
|
|
576
583
|
...context,
|
|
577
584
|
...(typeof rawSessionId === "string"
|
|
578
|
-
? {
|
|
585
|
+
? {
|
|
586
|
+
providerSessionId: encodeAcpProviderSessionId(
|
|
587
|
+
profileId,
|
|
588
|
+
rawSessionId,
|
|
589
|
+
/** @type {Uint8Array} */ (sessionTokenKey),
|
|
590
|
+
),
|
|
591
|
+
}
|
|
579
592
|
: {}),
|
|
580
593
|
...(context.requestId === undefined
|
|
581
594
|
? {}
|
|
@@ -696,9 +709,11 @@ export async function connectAcpProfile(profileId, options) {
|
|
|
696
709
|
|
|
697
710
|
let connection;
|
|
698
711
|
try {
|
|
699
|
-
connection = app.connect(
|
|
700
|
-
|
|
701
|
-
|
|
712
|
+
connection = connectWithSafeAcpSdkDiagnostics(() => app.connect(
|
|
713
|
+
createBoundedAcpStdioStream(child, {
|
|
714
|
+
maxLineBytes: descriptor.process.maxLineBytes,
|
|
715
|
+
}),
|
|
716
|
+
));
|
|
702
717
|
} catch (error) {
|
|
703
718
|
child.kill("SIGTERM");
|
|
704
719
|
const exited = await waitForExit(exitPromise, descriptor.process.killGraceMs);
|
|
@@ -1010,15 +1025,15 @@ function validateMcpServers(servers, descriptor, initializeResult) {
|
|
|
1010
1025
|
});
|
|
1011
1026
|
}
|
|
1012
1027
|
|
|
1013
|
-
/** @param {string} profileId @param {any} request */
|
|
1014
|
-
function protocolSessionListRequest(profileId, request) {
|
|
1028
|
+
/** @param {string} profileId @param {any} request @param {Uint8Array} key */
|
|
1029
|
+
function protocolSessionListRequest(profileId, request, key) {
|
|
1015
1030
|
if (!request || typeof request !== "object" || Array.isArray(request)) {
|
|
1016
1031
|
throw new AcpClientError("invalid_request", "ACP session/list request must be an object.");
|
|
1017
1032
|
}
|
|
1018
1033
|
const { cursor, _meta: _meta, ...rest } = request;
|
|
1019
1034
|
return {
|
|
1020
1035
|
...rest,
|
|
1021
|
-
...(cursor == null ? {} : { cursor: decodeAcpSessionCursor(profileId, cursor) }),
|
|
1036
|
+
...(cursor == null ? {} : { cursor: decodeAcpSessionCursor(profileId, cursor, key) }),
|
|
1022
1037
|
};
|
|
1023
1038
|
}
|
|
1024
1039
|
|
|
@@ -1090,18 +1105,23 @@ export async function logoutAcpProfile(profileId, options) {
|
|
|
1090
1105
|
* @returns {Promise<AcpSessionListResult>}
|
|
1091
1106
|
*/
|
|
1092
1107
|
export async function listAcpSessions(profileId, request = {}, options = /** @type {any} */ ({})) {
|
|
1093
|
-
const
|
|
1094
|
-
const
|
|
1108
|
+
const key = validateAcpSessionTokenKey(options?.acpSessionTokenKey);
|
|
1109
|
+
const protocolRequest = protocolSessionListRequest(profileId, request, key);
|
|
1110
|
+
const connection = await connectAcpProfile(profileId, {
|
|
1111
|
+
...options,
|
|
1112
|
+
operation: "list_sessions",
|
|
1113
|
+
acpSessionTokenKey: key,
|
|
1114
|
+
});
|
|
1095
1115
|
try {
|
|
1096
1116
|
const result = await connection.listSessions(protocolRequest);
|
|
1097
1117
|
return {
|
|
1098
1118
|
profileId,
|
|
1099
1119
|
sessions: (result.sessions || []).map((session) => ({
|
|
1100
1120
|
...sanitizeAcpHostValue(session, [session.sessionId, result.nextCursor]),
|
|
1101
|
-
providerSessionId: encodeAcpProviderSessionId(profileId, session.sessionId),
|
|
1121
|
+
providerSessionId: encodeAcpProviderSessionId(profileId, session.sessionId, key),
|
|
1102
1122
|
})),
|
|
1103
1123
|
nextCursor: typeof result.nextCursor === "string"
|
|
1104
|
-
? encodeAcpSessionCursor(profileId, result.nextCursor)
|
|
1124
|
+
? encodeAcpSessionCursor(profileId, result.nextCursor, key)
|
|
1105
1125
|
: null,
|
|
1106
1126
|
};
|
|
1107
1127
|
} finally {
|
|
@@ -1111,8 +1131,13 @@ export async function listAcpSessions(profileId, request = {}, options = /** @ty
|
|
|
1111
1131
|
|
|
1112
1132
|
/** @param {string} providerSessionId @param {AcpClientHostOptions} options */
|
|
1113
1133
|
export async function deleteAcpSession(providerSessionId, options) {
|
|
1114
|
-
const
|
|
1115
|
-
const
|
|
1134
|
+
const key = validateAcpSessionTokenKey(options?.acpSessionTokenKey);
|
|
1135
|
+
const { profileId, sessionId } = decodeAcpProviderSessionId(providerSessionId, key);
|
|
1136
|
+
const connection = await connectAcpProfile(profileId, {
|
|
1137
|
+
...options,
|
|
1138
|
+
operation: "delete_session",
|
|
1139
|
+
acpSessionTokenKey: key,
|
|
1140
|
+
});
|
|
1116
1141
|
try {
|
|
1117
1142
|
await connection.deleteSession(sessionId);
|
|
1118
1143
|
return { profileId, providerSessionId, deleted: true };
|