@mono-agent/agent-runtime 0.18.1 → 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 +32 -0
- package/README.md +50 -0
- 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/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 +98 -10
- package/types/agent/tools/web-controller.d.ts +5 -1
- package/types/agent/tools/web-search.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 +231 -20
|
@@ -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
|
}
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
claudeSandboxPolicyProblem,
|
|
26
26
|
} from "./claude-sandbox.js";
|
|
27
27
|
import { resolveSandboxPolicy } from "../../agent/tools/shared/tool-context.js";
|
|
28
|
+
import { createClaudeSubagentActivityNormalizer } from "./claude-subagent-activity.js";
|
|
28
29
|
|
|
29
30
|
const CODEX_CLI_SANDBOX_POLICY_UNSUPPORTED =
|
|
30
31
|
"Direct Codex CLI cannot enforce mono-agent's native srt sandbox scopes. Remove the mono-agent sandbox policy or use a Pi runtime for exact readableRoots, writableRoots, denyWrite, and network rules.";
|
|
@@ -449,6 +450,7 @@ export function buildCliCommand({
|
|
|
449
450
|
"-p",
|
|
450
451
|
"--output-format", "stream-json",
|
|
451
452
|
"--include-partial-messages",
|
|
453
|
+
"--forward-subagent-text",
|
|
452
454
|
"--verbose",
|
|
453
455
|
...(outputSchema ? ["--json-schema", JSON.stringify(outputSchema)] : []),
|
|
454
456
|
"--model", modelWithContextWindow(model, contextWindow),
|
|
@@ -615,8 +617,29 @@ export async function generateCliResponse(systemPrompt, options = {}) {
|
|
|
615
617
|
fileChangeSnapshots: new Map(),
|
|
616
618
|
thinkingBuffer: createThinkingBuffer(),
|
|
617
619
|
};
|
|
620
|
+
const subagentNormalizer = resolved.sdk === "claude-code"
|
|
621
|
+
? createClaudeSubagentActivityNormalizer()
|
|
622
|
+
: null;
|
|
623
|
+
function emitSubagentEvents(activityEvents) {
|
|
624
|
+
for (const activity of activityEvents) {
|
|
625
|
+
events.push(activity);
|
|
626
|
+
options.onEvent?.(activity);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
function drainSubagents(reason) {
|
|
630
|
+
if (!subagentNormalizer) return;
|
|
631
|
+
emitSubagentEvents(subagentNormalizer.drain(reason));
|
|
632
|
+
}
|
|
633
|
+
function observedSubagentCapabilities() {
|
|
634
|
+
if (!subagentNormalizer) return { invoked: null, names: [] };
|
|
635
|
+
return {
|
|
636
|
+
invoked: subagentNormalizer.subagentInvoked(),
|
|
637
|
+
names: subagentNormalizer.nativeSubagentsUsed(),
|
|
638
|
+
};
|
|
639
|
+
}
|
|
618
640
|
|
|
619
641
|
const stderrTail = createStderrTail({ limit: 8 * 1024 });
|
|
642
|
+
let abortHandler = null;
|
|
620
643
|
try {
|
|
621
644
|
const child = spawn(commandSpec.command, commandSpec.args, {
|
|
622
645
|
cwd: commandSpec.cwd,
|
|
@@ -638,43 +661,67 @@ export async function generateCliResponse(systemPrompt, options = {}) {
|
|
|
638
661
|
options.onEvent?.(ev);
|
|
639
662
|
return;
|
|
640
663
|
}
|
|
641
|
-
|
|
664
|
+
// Capture the provider session before a child event is consumed. Claude
|
|
665
|
+
// uses the same session id on parent and child records.
|
|
666
|
+
const candidateSessionId = raw.session_id ?? raw.sessionId ?? raw.thread_id ?? null;
|
|
667
|
+
if (typeof candidateSessionId === "string" && candidateSessionId.trim().length > 0) {
|
|
668
|
+
providerSessionId = candidateSessionId.trim();
|
|
669
|
+
}
|
|
670
|
+
const observation = subagentNormalizer?.observe(raw);
|
|
671
|
+
if (observation) emitSubagentEvents(observation.events);
|
|
672
|
+
// Child messages must not be normalized, added to parent text, counted as
|
|
673
|
+
// parent usage, or considered for the parent's StructuredOutput result.
|
|
674
|
+
if (observation?.consumed) return;
|
|
675
|
+
// A root user message may batch a background Agent launch acknowledgement
|
|
676
|
+
// with unrelated tool results. The normalizer removes only that launch
|
|
677
|
+
// block so the remaining parent activity still flows normally.
|
|
678
|
+
const parentRaw = observation?.forwarded ?? raw;
|
|
679
|
+
const ev = normalizeCliEvent(parentRaw, cliEventContext);
|
|
642
680
|
if (ev) {
|
|
643
681
|
events.push(ev);
|
|
644
682
|
options.onEvent?.(ev);
|
|
645
683
|
}
|
|
646
|
-
if (!isCodexReasoningEvent(
|
|
647
|
-
const text = textFromEvent(
|
|
684
|
+
if (!isCodexReasoningEvent(parentRaw)) {
|
|
685
|
+
const text = textFromEvent(parentRaw);
|
|
648
686
|
pushUniqueText(texts, text);
|
|
649
687
|
}
|
|
650
|
-
captureStructuredOutputFromRaw(
|
|
651
|
-
if (
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
// session_id on the init system message and again on the result event.
|
|
655
|
-
const candidateSessionId = raw.session_id ?? raw.sessionId ?? raw.thread_id ?? null;
|
|
656
|
-
if (typeof candidateSessionId === "string" && candidateSessionId.trim().length > 0) {
|
|
657
|
-
providerSessionId = candidateSessionId.trim();
|
|
658
|
-
}
|
|
659
|
-
if (raw.type === "error") {
|
|
660
|
-
const rawError = raw.message || raw.error || "cli error";
|
|
688
|
+
captureStructuredOutputFromRaw(parentRaw);
|
|
689
|
+
if (parentRaw.usage) usage = parentRaw.usage;
|
|
690
|
+
if (parentRaw.type === "error") {
|
|
691
|
+
const rawError = parentRaw.message || parentRaw.error || "cli error";
|
|
661
692
|
errorMessage = typeof rawError === "string" ? rawError : JSON.stringify(rawError);
|
|
662
693
|
failureKind = "provider_unavailable";
|
|
694
|
+
drainSubagents("subagent stopped because the Claude CLI stream failed");
|
|
663
695
|
}
|
|
664
|
-
const resultError = resultEventError(
|
|
696
|
+
const resultError = resultEventError(parentRaw, commandSpec.command);
|
|
665
697
|
if (resultError) {
|
|
666
698
|
errorMessage = resultError.message;
|
|
667
699
|
failureKind = resultError.failureKind;
|
|
700
|
+
drainSubagents("subagent stopped because the Claude CLI stream failed");
|
|
668
701
|
}
|
|
669
702
|
});
|
|
670
703
|
|
|
704
|
+
child.on("error", (error) => {
|
|
705
|
+
if (!errorMessage) errorMessage = error?.message || String(error);
|
|
706
|
+
failureKind ||= "provider_unavailable";
|
|
707
|
+
drainSubagents("subagent stopped because the Claude CLI process failed");
|
|
708
|
+
});
|
|
709
|
+
|
|
671
710
|
if (options.abortSignal) {
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
711
|
+
abortHandler = () => {
|
|
712
|
+
drainSubagents("subagent cancelled with the parent run");
|
|
713
|
+
child.kill("SIGTERM");
|
|
714
|
+
};
|
|
715
|
+
if (options.abortSignal.aborted) abortHandler();
|
|
716
|
+
else options.abortSignal.addEventListener("abort", abortHandler, { once: true });
|
|
675
717
|
}
|
|
676
718
|
|
|
677
719
|
const exitCode = await new Promise((resolve) => child.on("close", resolve));
|
|
720
|
+
drainSubagents(options.abortSignal?.aborted
|
|
721
|
+
? "subagent cancelled with the parent run"
|
|
722
|
+
: exitCode === 0
|
|
723
|
+
? "subagent stream closed before completion"
|
|
724
|
+
: "subagent stopped because the Claude CLI process failed");
|
|
678
725
|
const stderrText = stderrTail.toString().trim();
|
|
679
726
|
let cliErrorCode = null;
|
|
680
727
|
if (exitCode !== 0 && !errorMessage) errorMessage = stderrText || `${commandSpec.command} exited ${exitCode}`;
|
|
@@ -706,6 +753,7 @@ export async function generateCliResponse(systemPrompt, options = {}) {
|
|
|
706
753
|
cache_creation_tokens: cacheCreationTokens || null,
|
|
707
754
|
cost_usd: costUsd,
|
|
708
755
|
};
|
|
756
|
+
const subagentCapabilities = observedSubagentCapabilities();
|
|
709
757
|
return {
|
|
710
758
|
text,
|
|
711
759
|
structuredResult,
|
|
@@ -733,14 +781,18 @@ export async function generateCliResponse(systemPrompt, options = {}) {
|
|
|
733
781
|
promptCacheActive: (cachedTokens || 0) > 0 || (cacheCreationTokens || 0) > 0,
|
|
734
782
|
thinkingEnabled: null,
|
|
735
783
|
structuredOutputEnforced: !!options.outputSchema,
|
|
736
|
-
subagentInvoked:
|
|
784
|
+
subagentInvoked: subagentCapabilities.invoked,
|
|
737
785
|
mcpServersUsed: Object.keys(options.mcpServers || {}),
|
|
738
|
-
nativeSubagentsUsed:
|
|
786
|
+
nativeSubagentsUsed: subagentCapabilities.names,
|
|
739
787
|
toolCompactionApplied: false,
|
|
740
788
|
contextCompactionApplied: null,
|
|
741
789
|
}),
|
|
742
790
|
};
|
|
743
791
|
} catch (err) {
|
|
792
|
+
drainSubagents(options.abortSignal?.aborted
|
|
793
|
+
? "subagent cancelled with the parent run"
|
|
794
|
+
: "subagent stopped because the Claude CLI process failed");
|
|
795
|
+
const subagentCapabilities = observedSubagentCapabilities();
|
|
744
796
|
return {
|
|
745
797
|
text: texts[texts.length - 1] || null,
|
|
746
798
|
structuredResult,
|
|
@@ -766,14 +818,17 @@ export async function generateCliResponse(systemPrompt, options = {}) {
|
|
|
766
818
|
promptCacheActive: null,
|
|
767
819
|
thinkingEnabled: null,
|
|
768
820
|
structuredOutputEnforced: !!options.outputSchema,
|
|
769
|
-
subagentInvoked:
|
|
821
|
+
subagentInvoked: subagentCapabilities.invoked,
|
|
770
822
|
mcpServersUsed: Object.keys(options.mcpServers || {}),
|
|
771
|
-
nativeSubagentsUsed:
|
|
823
|
+
nativeSubagentsUsed: subagentCapabilities.names,
|
|
772
824
|
toolCompactionApplied: false,
|
|
773
825
|
contextCompactionApplied: null,
|
|
774
826
|
}),
|
|
775
827
|
};
|
|
776
828
|
} finally {
|
|
829
|
+
if (abortHandler && options.abortSignal) {
|
|
830
|
+
options.abortSignal.removeEventListener?.("abort", abortHandler);
|
|
831
|
+
}
|
|
777
832
|
try { rmSync(dir, { recursive: true, force: true }); } catch {}
|
|
778
833
|
}
|
|
779
834
|
}
|