@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.
@@ -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
- const reason = providerFailures.map((entry) => entry.message).filter(Boolean).join("; ")
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}`, networkDenied ? "network_denied" : "backend_unavailable", startedAt, {
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
- const duck = await searchDuckDuckGo(query, options);
183
- if (duck.ok && duck.results.length > 0) return duck;
184
- if (!duck.ok) failures.push(duck);
185
- if (options.signal?.aborted) return abortedSearch(duck.backend);
186
- const startpage = await searchStartpage(query, options);
187
- if (startpage.ok) return startpage;
188
- failures.push(startpage);
189
- if (duck.ok) return duck;
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 failures[failures.length - 1] || {
192
- ok: false,
193
- backend: config.backend,
194
- message: "No configured search backend.",
195
- retryable: false,
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
- async function searchDuckDuckGo(query, options) {
259
- const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
260
- if (!options.sandbox.networkAllowsUrl(options.policy, url)) {
261
- return { ok: false, backend: "duckduckgo", message: "Network access denied by sandbox policy.", retryable: false };
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
- const response = await options.fetchImpl(url, {
265
- headers: {
266
- Accept: "text/html,application/xhtml+xml",
267
- "Accept-Language": options.language || "en-US,en;q=0.8",
268
- "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) mono-agent-web/1",
269
- },
270
- signal: requestSignal(options.signal),
271
- redirect: "error",
272
- });
273
- const html = await readLimitedText(response);
274
- if (!response.ok) {
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: "duckduckgo",
278
- message: `DuckDuckGo HTTP ${response.status}`,
279
- retryable: response.status === 429 || response.status >= 500,
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
- return { ok: true, backend: "duckduckgo", results: parseDuckDuckGoResults(html) };
283
- } catch (error) {
284
- return fetchFailure("duckduckgo", error);
285
- }
286
- }
287
-
288
- async function searchStartpage(query, options) {
289
- const url = `https://www.startpage.com/sp/search?query=${encodeURIComponent(query)}`;
290
- if (!options.sandbox.networkAllowsUrl(options.policy, url)) {
291
- return { ok: false, backend: "startpage", message: "Network access denied by sandbox policy.", retryable: false };
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: "startpage",
308
- message: `Startpage HTTP ${response.status}`,
309
- retryable: response.status === 429 || response.status >= 500,
466
+ backend: spec.backend,
467
+ message: `${spec.label} HTTP ${response.status}`,
468
+ retryable: response.status >= 500,
310
469
  };
311
470
  }
312
- return { ok: true, backend: "startpage", results: parseStartpageResults(html) };
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("startpage", error);
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
- function fetchFailure(backend, error) {
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
- || ["ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EAI_AGAIN"].includes(error?.code ?? error?.cause?.code);
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: `${backend} request failed: ${error?.message || String(error)}`,
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
- const ev = normalizeCliEvent(raw, cliEventContext);
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(raw)) {
647
- const text = textFromEvent(raw);
684
+ if (!isCodexReasoningEvent(parentRaw)) {
685
+ const text = textFromEvent(parentRaw);
648
686
  pushUniqueText(texts, text);
649
687
  }
650
- captureStructuredOutputFromRaw(raw);
651
- if (raw.usage) usage = raw.usage;
652
- // intelligence-ramp Phase 5.1: capture session_id from CLI events so the
653
- // coordinator can chain it on the next continuation. Claude Code emits
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(raw, commandSpec.command);
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
- const abort = () => child.kill("SIGTERM");
673
- if (options.abortSignal.aborted) abort();
674
- else options.abortSignal.addEventListener("abort", abort, { once: true });
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: null,
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: null,
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
  }