@lincoln504/pi-research 1.3.9 → 1.3.11

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.
@@ -129,6 +129,19 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
129
129
  });
130
130
  logger.info(`[ResearchOrchestrationService] Acquired research run slot ${runSlot.slotIndex} (cap ${semaphore.getMaxSlots()}).`);
131
131
  } catch (err) {
132
+ const isTerminal =
133
+ err instanceof ResearchRunCapacityError || (err instanceof Error && err.name === 'AbortError');
134
+ if (isTerminal) {
135
+ // The queue notice above may already have announced this run to the
136
+ // observer (onRunQueued); rethrowing with no terminal event breaks the
137
+ // "exactly one of onComplete/onError" contract both orchestrators keep —
138
+ // an observer-driven consumer saw run_queued and then silence. No
139
+ // orchestrator exists yet, so this cannot double-fire. Observer throws
140
+ // stay isolated, as everywhere else.
141
+ try {
142
+ (observer as { onError?: (e: Error) => void } | undefined)?.onError?.(err as Error);
143
+ } catch { /* observer isolation */ }
144
+ }
132
145
  if (err instanceof ResearchRunCapacityError) throw err; // capacity exhausted → fail fast
133
146
  // A cancel while queueing for a slot must stay cancelled. Fail-open here would
134
147
  // start the very run the user just aborted.
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { parseCitations } from '../utils/text-utils.ts';
13
13
  import { normalizeCitations, formatCitedLinks, rewriteCitationMarkers, type GlobalCitation } from '../utils/citation-utils.ts';
14
- import { lastCitedLinksHeaderIndex } from '../utils/text-utils.ts';
14
+ import { lastCitedLinksHeaderIndex, lastCitedLinksHeader } from '../utils/text-utils.ts';
15
15
  import { getScrapedLinks, isTranscribedLink, normalizeUrl } from '../utils/shared-links.ts';
16
16
  import { stripTrailingLlmPunctuation } from '../utils/url-utils.ts';
17
17
  import { logger } from '../logger.ts';
@@ -321,7 +321,9 @@ export class ResearchSynthesisService implements IService {
321
321
  if (globalId !== undefined) localToGlobal.set(localKey, globalId);
322
322
  else droppedNumbers.add(localKey);
323
323
  });
324
- let body = synthesis.slice(0, citedHeaderIdx).trimEnd();
324
+ // lineStart (not the 'C' offset): slicing at textStart left the tolerated
325
+ // `##`/`**`/`>` marker dangling as a stray line at the end of the body.
326
+ let body = synthesis.slice(0, lastCitedLinksHeader(synthesis).lineStart).trimEnd();
325
327
  if (localToGlobal.size > 0 || droppedNumbers.size > 0) {
326
328
  body = rewriteCitationMarkers(body, localToGlobal, (n) => droppedNumbers.has(n));
327
329
  }
@@ -9,7 +9,7 @@ import type { SystemResearchState } from './deep-research-types.ts';
9
9
  import { createResearcherSession } from './researcher.ts';
10
10
  import { registerScrapedLinks } from '../utils/shared-links.ts';
11
11
  import { ensureAssistantResponse } from '../utils/text-utils.ts';
12
- import { extractUsage } from '../types/llm.ts';
12
+ import { recordLlmUsage } from '../utils/llm-usage.ts';
13
13
  import { logger } from '../logger.ts';
14
14
  import { metrics } from '../utils/metrics.ts';
15
15
  import { ServiceNames,
@@ -347,13 +347,15 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
347
347
 
348
348
  const rawUsage = msg['usage'] as any;
349
349
  if (rawUsage) {
350
- const { tokens, cost } = extractUsage(resolvedModel, rawUsage);
351
-
350
+ // Observer exceptions must not kill the researcher, so hand recordLlmUsage
351
+ // a safeObserve-wrapped sink rather than the raw observer.
352
+ const { tokens, cost } = recordLlmUsage(resolvedModel, rawUsage, {
353
+ component: 'researcher',
354
+ complexity,
355
+ observer: { onTokensConsumed: (t, c) => safeObserve(() => observer?.onTokensConsumed?.(t, c)) },
356
+ });
352
357
  if (tokens > 0 || cost > 0) {
353
- metrics.increment('llm_tokens_total', tokens, { component: 'researcher', complexity: String(complexity) });
354
- metrics.increment('llm_cost_total', cost, { component: 'researcher', complexity: String(complexity) });
355
358
  safeObserve(() => observer?.onResearcherProgress?.(id, undefined, tokens, cost));
356
- safeObserve(() => observer?.onTokensConsumed?.(tokens, cost));
357
359
  }
358
360
  }
359
361
  } else if (event.type === 'tool_execution_start') {
@@ -28,7 +28,7 @@ import { redactSecrets } from '../utils/log-utils.ts';
28
28
  import { exportResearchReport, appendExportMessage } from '../utils/research-export.ts';
29
29
  import { validateAndSanitizeQuery } from '../utils/input-validation.ts';
30
30
  import { validateInitialLinks, MAX_INITIAL_LINKS, MAX_INITIAL_LINK_CHARS } from '../utils/url-utils.ts';
31
- import { startResearchSession, registerSessionAbort, clearSteeringMessages, getPiActivePanels } from '../orchestration/session-state.ts';
31
+ import { startResearchSession, endResearchSession, registerSessionAbort, clearSteeringMessages, getPiActivePanels } from '../orchestration/session-state.ts';
32
32
  import { createResearchTuiManager, hideWorkingIndicator } from '../tui/research-tui-manager.ts';
33
33
  import { createCleanupFunction } from '../cleanup/research-cleanup.ts';
34
34
  import { createResearchObserver, createObserverState, stopObserverWaveAnimation } from '../observers/research-observer-impl.ts';
@@ -339,6 +339,13 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
339
339
  registerSessionAbort(piSessionId, sessionResearchId, internalAbort);
340
340
 
341
341
  cleanup = async () => {
342
+ // Free what startResearchSession/registerSessionAbort created — the
343
+ // failures/failureReasons entries and the AbortController live in the
344
+ // module-level piSessions map keyed by the STABLE pi session id, so
345
+ // without this every headless run leaked them for the process
346
+ // lifetime (the TUI branch frees them via createCleanupFunction, the
347
+ // SDK in its own finally; this branch freed only steering).
348
+ endResearchSession(piSessionId, sessionResearchId);
342
349
  clearSteeringMessages(piSessionId);
343
350
  };
344
351
 
@@ -1,4 +1,4 @@
1
- import { parseCitations, lastCitedLinksHeaderIndex, type Citation } from './text-utils.ts';
1
+ import { parseCitations, lastCitedLinksHeader, type Citation } from './text-utils.ts';
2
2
  import { normalizeUrl } from './shared-links.ts';
3
3
 
4
4
  export interface GlobalCitation extends Citation {
@@ -66,7 +66,9 @@ export function normalizeCitations(reports: ReadonlyMap<string, string>): {
66
66
  // line-leading, all-caps header, never an incidental "cited links" mention in
67
67
  // prose (an unanchored split there would drop real findings before the
68
68
  // evaluator ever sees them).
69
- const headerIdx = lastCitedLinksHeaderIndex(report);
69
+ // lineStart (not textStart): slicing at the 'C' left the tolerated `##`/`**`/`>`
70
+ // marker dangling at the end of the kept body.
71
+ const headerIdx = lastCitedLinksHeader(report).lineStart;
70
72
  let content = headerIdx >= 0 ? report.slice(0, headerIdx) : report;
71
73
 
72
74
  // Renumber local [N] markers to their global ids, and DELETE the ones that could
@@ -253,17 +253,31 @@ function isPlausibleCitationUrl(url: string): boolean {
253
253
  * against the `CITED LINKS\n...` block that formatCitedLinks() emits.
254
254
  */
255
255
  export function lastCitedLinksHeaderIndex(text: string): number {
256
+ return lastCitedLinksHeader(text).textStart;
257
+ }
258
+
259
+ /**
260
+ * Both offsets of the last CITED LINKS header: `textStart` points at the 'C'
261
+ * (what parseCitations slices FROM), `lineStart` points at the start of the
262
+ * header line BEFORE any `#`/`>`/`**` markers (what body-replacement slices TO).
263
+ * Slicing the body at textStart left the tolerated markdown marker dangling at
264
+ * the end of the body — the rebuilt report then carried a stray `##` or `**`
265
+ * line whenever the model wrote `## CITED LINKS` instead of the plain form.
266
+ */
267
+ export function lastCitedLinksHeader(text: string): { lineStart: number; textStart: number } {
256
268
  // Case-insensitive but LINE-LEADING: a mid-prose "...the cited links below..." has
257
269
  // non-whitespace before it on the line, so it cannot match — only a header at the
258
270
  // start of a line (after optional markdown markers) does.
259
271
  const re = /(^|\n)([ \t]*(?:#{1,6}[ \t]*|>[ \t]*|\*\*)?)CITED LINKS\b/gi;
260
- let last = -1;
272
+ let lineStart = -1;
273
+ let textStart = -1;
261
274
  let m: RegExpExecArray | null;
262
275
  while ((m = re.exec(text)) !== null) {
276
+ lineStart = m.index + m[1]!.length;
263
277
  // Offset of the 'C' in CITED LINKS = match start + leading newline + markers.
264
- last = m.index + m[1]!.length + m[2]!.length;
278
+ textStart = m.index + m[1]!.length + m[2]!.length;
265
279
  }
266
- return last;
280
+ return { lineStart, textStart };
267
281
  }
268
282
 
269
283
  /**
@@ -250,27 +250,73 @@ export function ssrfSafeLookup(
250
250
  });
251
251
  }
252
252
 
253
- // undici Agent used as the per-request dispatcher for scrape fetches. Cached
254
- // after first construction. `undefined` = not yet attempted, `null` = undici
255
- // unavailable (fall back to plain fetch with request-time validation only).
256
- let cachedSsrfDispatcher: unknown | null | undefined = undefined;
253
+ /**
254
+ * True when the error is one of our SSRF blocks — request-time validation
255
+ * (message contains "not allowed") or the connect-time DNS pin (`ESSRFBLOCKED`).
256
+ *
257
+ * The connect-time error never surfaces directly: undici wraps it in
258
+ * `TypeError: fetch failed` with the real error down the `cause` chain, so a
259
+ * message check on the outer error alone files a connect-time block as a generic
260
+ * fetch error. Walk the chain (bounded — causes can be cyclic) instead.
261
+ */
262
+ export function isSsrfBlockError(error: unknown): boolean {
263
+ let e: unknown = error;
264
+ for (let depth = 0; depth < 5 && e instanceof Error; depth++) {
265
+ if ((e as NodeJS.ErrnoException).code === 'ESSRFBLOCKED' || e.message.includes('not allowed')) {
266
+ return true;
267
+ }
268
+ e = (e as { cause?: unknown }).cause;
269
+ }
270
+ return false;
271
+ }
272
+
273
+ /** A fetch-shaped callable plus the dispatcher it must be invoked with. */
274
+ export interface SsrfSafeFetcher {
275
+ /** undici's own fetch — NOT the global one. See getSsrfSafeFetcher. */
276
+ fetch: (url: string, init: Record<string, unknown>) => Promise<Response>;
277
+ dispatcher: unknown;
278
+ }
279
+
280
+ // Cached after first construction. `undefined` = not yet attempted, `null` =
281
+ // undici unavailable (fall back to the global fetch with request-time
282
+ // validation only).
283
+ let cachedSsrfFetcher: SsrfSafeFetcher | null | undefined = undefined;
257
284
 
258
285
  /**
259
286
  * Build (once) an undici Agent whose connector resolves via ssrfSafeLookup, so
260
287
  * every scrape fetch — including each manually-followed redirect hop — connects
261
- * only to a validated public address. Returns null if undici can't be loaded;
262
- * callers then fall back to plain fetch (request-time validation still applies).
288
+ * only to a validated public address, PAIRED with the fetch implementation that
289
+ * can actually accept it.
290
+ *
291
+ * The pairing is the whole point. A dispatcher is only honoured by the undici
292
+ * that created it: Node's global `fetch` is backed by the undici BUILT INTO the
293
+ * runtime (6.21.0 on Node 22, 7.24.4 on Node 25), while this package depends on
294
+ * undici 8.x. Handing a v8 `Agent` to the global fetch throws
295
+ * `UND_ERR_INVALID_ARG: invalid onRequestStart method` on every request, on
296
+ * every supported Node — which is exactly what it did: the entire fetch scrape
297
+ * path failed instantly, every URL was retried once and then fell back to the
298
+ * stealth browser (~4.5s per page against ~250ms), and the connect-time SSRF pin
299
+ * this dispatcher exists for never engaged even once. The browser fallback made
300
+ * it invisible: scrapes still succeeded, just 15-20x slower.
301
+ *
302
+ * Returning the two together makes the invalid combination unrepresentable —
303
+ * a caller cannot end up holding a dispatcher without the fetch that honours it.
304
+ * Returns null if undici can't be loaded; callers then use the global fetch with
305
+ * no dispatcher (request-time validation still applies).
263
306
  */
264
- export async function getSsrfSafeDispatcher(): Promise<unknown | null> {
265
- if (cachedSsrfDispatcher !== undefined) return cachedSsrfDispatcher as unknown | null;
307
+ export async function getSsrfSafeFetcher(): Promise<SsrfSafeFetcher | null> {
308
+ if (cachedSsrfFetcher !== undefined) return cachedSsrfFetcher;
266
309
  try {
267
- const { Agent } = await import('undici');
268
- cachedSsrfDispatcher = new Agent({ connect: { lookup: ssrfSafeLookup } });
310
+ const { Agent, fetch: undiciFetch } = await import('undici');
311
+ cachedSsrfFetcher = {
312
+ fetch: undiciFetch as unknown as SsrfSafeFetcher['fetch'],
313
+ dispatcher: new Agent({ connect: { lookup: ssrfSafeLookup } }),
314
+ };
269
315
  } catch (e) {
270
316
  logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
271
- cachedSsrfDispatcher = null;
317
+ cachedSsrfFetcher = null;
272
318
  }
273
- return cachedSsrfDispatcher as unknown | null;
319
+ return cachedSsrfFetcher;
274
320
  }
275
321
 
276
322
  /**
@@ -23,7 +23,7 @@ import {
23
23
  import {
24
24
  FETCH_LAYER_TIMEOUT,
25
25
  } from './types.ts';
26
- import { getRandomUserAgent, extractDomain, validateUrlForSSRF, validateContent, createNativeMarkdownConverter, createJsMarkdownConverter, getSsrfSafeDispatcher, formatErrorWithCause, isBenignScrapeFailure, } from './scraper-utils.ts';
26
+ import { getRandomUserAgent, extractDomain, validateUrlForSSRF, validateContent, createNativeMarkdownConverter, createJsMarkdownConverter, getSsrfSafeFetcher, formatErrorWithCause, isBenignScrapeFailure, isSsrfBlockError, } from './scraper-utils.ts';
27
27
  import { isTransientError, abortableDelay } from './retry-utils.ts';
28
28
  import { safeUnref } from '../utils/safe-unref.ts';
29
29
  import { readBodyCapped, BodyTooLargeError } from '../utils/http-body.ts';
@@ -208,7 +208,7 @@ function decodeHtmlBody(bytes: Uint8Array, contentTypeHeader: string): string {
208
208
  * counts are precisely what production incidents are diagnosed from.
209
209
  */
210
210
  function trackFetchLayerFailure(url: string, error: unknown): void {
211
- const isSsrf = error instanceof Error && error.message.includes('not allowed');
211
+ const isSsrf = isSsrfBlockError(error);
212
212
  errorTracker.trackError(error instanceof Error ? error : String(error), {
213
213
  component: 'scrapers',
214
214
  operation: 'fetch',
@@ -226,7 +226,12 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
226
226
  // closing the DNS-rebinding TOCTOU gap between validateUrlForSSRF (request
227
227
  // time) and fetch's own re-resolution (connect time). Null when undici is
228
228
  // unavailable — request-time validation above still applies.
229
- const dispatcher = await getSsrfSafeDispatcher();
229
+ //
230
+ // The fetch implementation comes WITH the dispatcher and must be used with it:
231
+ // a dispatcher is only honoured by the undici that built it, and the global
232
+ // fetch is backed by Node's bundled undici, not ours. See getSsrfSafeFetcher.
233
+ const fetcher = await getSsrfSafeFetcher();
234
+ const doFetch = fetcher?.fetch ?? ((u: string, init: Record<string, unknown>) => fetch(u, init as RequestInit));
230
235
 
231
236
  const controller = new AbortController();
232
237
  const timeoutId = setTimeout(() => controller.abort(), FETCH_LAYER_TIMEOUT);
@@ -257,16 +262,16 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
257
262
  const userAgent = getRandomUserAgent();
258
263
 
259
264
  for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
260
- response = await fetch(currentUrl, {
265
+ response = await doFetch(currentUrl, {
261
266
  signal: controller.signal,
262
267
  redirect: 'manual',
263
268
  headers: {
264
269
  'User-Agent': userAgent,
265
270
  'Accept': 'text/html,application/xhtml+xml,application/pdf,*/*;q=0.8',
266
271
  },
267
- // RequestInit's standard type omits undici's `dispatcher`; cast to attach it.
268
- ...(dispatcher ? { dispatcher } : {}),
269
- } as RequestInit);
272
+ // Only ever attached alongside the fetch that honours it (see above).
273
+ ...(fetcher ? { dispatcher: fetcher.dispatcher } : {}),
274
+ });
270
275
 
271
276
  if (response.status >= 300 && response.status < 400) {
272
277
  const location = response.headers.get('location');
@@ -380,7 +385,7 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
380
385
  // call in a retry, and tracking per attempt would record one failure twice —
381
386
  // inflating exactly the error counts these numbers are read to diagnose.
382
387
  // scrapeSingle calls trackFetchLayerFailure once with the final error.
383
- if (error instanceof Error && error.message.includes('not allowed')) {
388
+ if (isSsrfBlockError(error)) {
384
389
  metrics.increment('scrape_operations_total', 1, { layer: 'fetch', status: 'ssrf_blocked' });
385
390
  metrics.observe('scrape_latency_ms', Date.now() - fetchStart, { layer: 'fetch', status: 'ssrf_blocked' });
386
391
  throw error;