@lincoln504/pi-research 1.0.10 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -8
- package/dist/cli.mjs +538 -66
- package/dist/thread-worker.mjs +39 -1
- package/docs/ARCHITECTURE.md +24 -0
- package/docs/CHANGELOG.md +40 -0
- package/docs/CONFIGURATION.md +2 -0
- package/docs/SDK.md +9 -0
- package/package.json +5 -2
- package/skills/pi-research/SKILL.md +11 -2
- package/src/cli.ts +40 -2
- package/src/core/interfaces/observer-interfaces.ts +9 -0
- package/src/core/interfaces/service-names.ts +2 -0
- package/src/infrastructure/browser/browser-client.ts +19 -1
- package/src/infrastructure/browser/browser-error-utils.ts +36 -4
- package/src/infrastructure/browser/browser-server.ts +70 -0
- package/src/infrastructure/browser/browser-task-scheduler.ts +19 -3
- package/src/infrastructure/browser/scheduler-factory.ts +1 -1
- package/src/infrastructure/browser/task-execution-service.ts +86 -9
- package/src/infrastructure/browser/thread-worker.mjs +39 -1
- package/src/infrastructure/process-lifecycle-service.ts +21 -3
- package/src/infrastructure/research-run-semaphore.ts +323 -0
- package/src/infrastructure/service-initialization.ts +20 -0
- package/src/orchestration/deep-research-orchestrator.ts +11 -0
- package/src/orchestration/headless-observer.ts +4 -0
- package/src/orchestration/research-orchestration-service.ts +70 -0
- package/src/orchestration/research-synthesis-service.ts +171 -15
- package/src/web-research/retry-utils.ts +9 -3
- package/src/web-research/scraper-utils.ts +43 -0
- package/src/web-research/web-scraper.ts +67 -40
|
@@ -37,6 +37,7 @@ import type { Config } from '../config.ts';
|
|
|
37
37
|
import { getConfig, DEFAULTS } from '../config.ts';
|
|
38
38
|
import { getCachedScrapedContent, normalizeUrl, cleanupSharedLinks } from '../utils/shared-links.ts';
|
|
39
39
|
import { runResearcher } from './researcher-executor.ts';
|
|
40
|
+
import { ResearchRunSemaphore, ResearchRunCapacityError } from '../infrastructure/research-run-semaphore.ts';
|
|
40
41
|
import { recordResearcherFailure, shouldStopResearch, createResearchStopError } from './session-state.ts';
|
|
41
42
|
import type { ResearchSessionService } from './research-session-service.ts';
|
|
42
43
|
import { QuickResearchOrchestrator } from './quick-research-orchestrator.ts';
|
|
@@ -107,6 +108,36 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
|
|
|
107
108
|
|
|
108
109
|
const researchStart = Date.now();
|
|
109
110
|
|
|
111
|
+
// Cross-process run-cap: acquire one of N slots so concurrent research runs
|
|
112
|
+
// can't saturate the shared leader-elected browser/embedding pool and degrade
|
|
113
|
+
// into ~60-error failures. Over-cap runs QUEUE for a slot (announced once via
|
|
114
|
+
// the observer) rather than failing, because an extra run is a legitimate
|
|
115
|
+
// request to do work, not an error. Fail-OPEN on internal/IO errors (a
|
|
116
|
+
// semaphore bug must never break research); only a genuine
|
|
117
|
+
// ResearchRunCapacityError — nothing freed within the whole queue window —
|
|
118
|
+
// propagates. The slot is released in finally.
|
|
119
|
+
let runSlot: { slotIndex: number; release(): Promise<void> } | null = null;
|
|
120
|
+
try {
|
|
121
|
+
const container = tryGetServiceContainerFromCtx(ctx);
|
|
122
|
+
const semaphore = await getService<ResearchRunSemaphore>(ServiceNames.RESEARCH_RUN_SEMAPHORE, ctx, container);
|
|
123
|
+
runSlot = await semaphore.acquire(undefined, signal, (slots, maxWaitMs) => {
|
|
124
|
+
// The queue notice fires BEFORE any orchestrator exists, so `observer` is
|
|
125
|
+
// still exactly what the caller passed: either a ResearchObserver, or the
|
|
126
|
+
// HeadlessObserverOptions bag (only `onProgress`) that the CLI supplies and
|
|
127
|
+
// the orchestrator would normally wrap later. Dispatch to whichever shape
|
|
128
|
+
// is present — handling only the former silently drops the notice on the
|
|
129
|
+
// CLI, which is the very front-end that most needs it.
|
|
130
|
+
notifyRunQueued(observer, slots, maxWaitMs);
|
|
131
|
+
});
|
|
132
|
+
logger.info(`[ResearchOrchestrationService] Acquired research run slot ${runSlot.slotIndex} (cap ${semaphore.getMaxSlots()}).`);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
if (err instanceof ResearchRunCapacityError) throw err; // capacity exhausted → fail fast
|
|
135
|
+
// A cancel while queueing for a slot must stay cancelled. Fail-open here would
|
|
136
|
+
// start the very run the user just aborted.
|
|
137
|
+
if (err instanceof Error && err.name === 'AbortError') throw err;
|
|
138
|
+
logger.warn(`[ResearchOrchestrationService] Run-cap unavailable, proceeding without it: ${err instanceof Error ? err.message : String(err)}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
110
141
|
let result: string;
|
|
111
142
|
try {
|
|
112
143
|
if (depth === 0) {
|
|
@@ -149,6 +180,20 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
|
|
|
149
180
|
metrics.observe('research_manager_latency_ms', researchDuration, { depth: String(depth), status: 'error', source: 'extension' });
|
|
150
181
|
metrics.increment('research_manager_requests_total', 1, { depth: String(depth), status: 'error', source: 'extension' });
|
|
151
182
|
throw error;
|
|
183
|
+
} finally {
|
|
184
|
+
// Always release the run-cap slot — on success, error, or abort (the
|
|
185
|
+
// orchestrator throws on AbortSignal, hitting this finally before rethrow).
|
|
186
|
+
if (runSlot) {
|
|
187
|
+
// Never let a release failure mask the run's own outcome — but do report it.
|
|
188
|
+
// A slot that fails to release stays held for the lifetime of THIS process
|
|
189
|
+
// (liveness reclaim only fires once the owner is gone), so it silently costs
|
|
190
|
+
// one unit of capacity for every subsequent run in the session.
|
|
191
|
+
await runSlot.release().catch((relErr) => {
|
|
192
|
+
logger.warn(
|
|
193
|
+
`[ResearchOrchestrationService] Failed to release run slot ${runSlot?.slotIndex}; capacity is reduced until this process exits: ${relErr instanceof Error ? relErr.message : String(relErr)}`,
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
152
197
|
}
|
|
153
198
|
}
|
|
154
199
|
|
|
@@ -548,3 +593,28 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
|
|
|
548
593
|
}
|
|
549
594
|
}
|
|
550
595
|
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Deliver the "your run is queued behind other runs" notice to whichever observer
|
|
599
|
+
* shape the caller supplied.
|
|
600
|
+
*
|
|
601
|
+
* This fires before the orchestrator normalizes the observer, so both forms are
|
|
602
|
+
* live here: a {@link ResearchObserver} instance (SDK / pi extension) or the
|
|
603
|
+
* {@link HeadlessObserverOptions} bag carrying only `onProgress` (the CLI and
|
|
604
|
+
* agent skill). A queued run is otherwise completely silent for the whole acquire
|
|
605
|
+
* wait, which is indistinguishable from a hang.
|
|
606
|
+
*
|
|
607
|
+
* Never throws: a front-end callback must not be able to fail the run it is
|
|
608
|
+
* merely reporting on.
|
|
609
|
+
*/
|
|
610
|
+
function notifyRunQueued(observer: unknown, slots: number, maxWaitMs: number): void {
|
|
611
|
+
try {
|
|
612
|
+
const obs = observer as
|
|
613
|
+
| { onRunQueued?: (s: number, w: number) => void; onProgress?: (e: string, d?: unknown) => void }
|
|
614
|
+
| undefined;
|
|
615
|
+
if (typeof obs?.onRunQueued === 'function') obs.onRunQueued(slots, maxWaitMs);
|
|
616
|
+
else if (typeof obs?.onProgress === 'function') obs.onProgress('run_queued', { slots, maxWaitMs });
|
|
617
|
+
} catch (err) {
|
|
618
|
+
logger.debug(`[ResearchOrchestrationService] run_queued notification threw: ${err instanceof Error ? err.message : String(err)}`);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
@@ -13,6 +13,7 @@ import { parseCitations } from '../utils/text-utils.ts';
|
|
|
13
13
|
import { normalizeCitations, formatCitedLinks, type GlobalCitation } from '../utils/citation-utils.ts';
|
|
14
14
|
import { lastCitedLinksHeaderIndex } from '../utils/text-utils.ts';
|
|
15
15
|
import { getScrapedLinks, isTranscribedLink, normalizeUrl } from '../utils/shared-links.ts';
|
|
16
|
+
import { stripTrailingLlmPunctuation } from '../utils/url-utils.ts';
|
|
16
17
|
import { logger } from '../logger.ts';
|
|
17
18
|
import { ServiceLifecycle, type IService } from '../core/service-registry.ts';
|
|
18
19
|
import { ServiceNames } from '../core/interfaces/service-names.ts';
|
|
@@ -26,6 +27,125 @@ import type { SteeringMessage } from './session-state.ts';
|
|
|
26
27
|
const NO_SOURCES_NOTE =
|
|
27
28
|
'CITED LINKS\n_No web sources were successfully retrieved for this report — searches or page fetches may have been blocked (for example by site bot-protection) or returned no usable results. Treat the findings above as unverified._';
|
|
28
29
|
|
|
30
|
+
// Appended when the model wrote its own links but the run retrieved nothing we could
|
|
31
|
+
// verify them against. The block is kept (it may still be a useful lead) but must not
|
|
32
|
+
// read as though the engine checked it.
|
|
33
|
+
const UNVERIFIED_LINKS_NOTE =
|
|
34
|
+
'_None of the links above could be verified: no page fetch succeeded this session and no citation could be matched to retrieved content. They may be inaccurate or invented — check each one before relying on it._';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Largest `[N]` treated as a citation marker. Inline markers index a source list
|
|
38
|
+
* that is realistically well under this; the bound exists so an ordinary bracketed
|
|
39
|
+
* number in prose — a year (`[2023]`), an issue id, an array index — is left alone
|
|
40
|
+
* instead of being silently deleted as a "dangling citation".
|
|
41
|
+
*/
|
|
42
|
+
const MAX_PLAUSIBLE_CITATION_MARKER = 200;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Rewrite inline `[N]` citation markers in a prose body: renumber the ones we can
|
|
46
|
+
* map, delete the ones known to dangle, leave everything else untouched.
|
|
47
|
+
*
|
|
48
|
+
* Deletion is whitespace-aware rather than followed by a global space collapse.
|
|
49
|
+
* A blanket `text.replace(/ +/g, ' ')` also eats *leading* indentation, which
|
|
50
|
+
* silently flattens nested list levels into one and turns 4-space indented code
|
|
51
|
+
* blocks into paragraphs — corrupting the structure of every report that contains
|
|
52
|
+
* them. So the pad preceding a deleted marker is consumed only when the marker is
|
|
53
|
+
* mid-line (i.e. real prose spacing); line-leading indentation is preserved.
|
|
54
|
+
*
|
|
55
|
+
* Code is skipped entirely. A report may quote code containing bracketed integers
|
|
56
|
+
* (`items[12]`), and on the no-CITED-LINKS path *every* out-of-range marker is treated
|
|
57
|
+
* as dangling — so without this guard a quoted array index would be silently deleted
|
|
58
|
+
* from the user's code sample. Fenced blocks are tracked across lines; single-backtick
|
|
59
|
+
* spans are detected by an odd backtick count earlier on the same line.
|
|
60
|
+
*
|
|
61
|
+
* @param remap local marker number → verified global id
|
|
62
|
+
* @param isDangling whether a marker not present in `remap` should be deleted
|
|
63
|
+
*/
|
|
64
|
+
function rewriteCitationMarkers(
|
|
65
|
+
text: string,
|
|
66
|
+
remap: Map<number, number>,
|
|
67
|
+
isDangling: (n: number) => boolean,
|
|
68
|
+
): string {
|
|
69
|
+
const inFence = fencedCodeMask(text);
|
|
70
|
+
return text.replace(/([ \t]*)\[(\d+)\]/g, (match, pad: string, digits: string, offset: number) => {
|
|
71
|
+
const n = parseInt(digits, 10);
|
|
72
|
+
const mapped = remap.get(n);
|
|
73
|
+
if (mapped !== undefined) return `${pad}[${mapped}]`;
|
|
74
|
+
// Citation numbering is 1-based; `[0]` is always an index or a literal, never a marker.
|
|
75
|
+
if (n < 1 || n > MAX_PLAUSIBLE_CITATION_MARKER || !isDangling(n)) return match;
|
|
76
|
+
if (inFence(offset) || inInlineCodeSpan(text, offset)) return match;
|
|
77
|
+
// Preserve indentation when nothing but whitespace precedes on this line;
|
|
78
|
+
// otherwise drop the pad too so no double space is left behind.
|
|
79
|
+
const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
|
|
80
|
+
return text.slice(lineStart, offset).trim() === '' ? pad : '';
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build an `offset → is inside a ``` fenced block` predicate for `text`.
|
|
86
|
+
*
|
|
87
|
+
* Computed once per rewrite (a single line scan) rather than per marker, so marker
|
|
88
|
+
* rewriting stays linear in the body length.
|
|
89
|
+
*/
|
|
90
|
+
function fencedCodeMask(text: string): (offset: number) => boolean {
|
|
91
|
+
const ranges: Array<[number, number]> = [];
|
|
92
|
+
let open = -1;
|
|
93
|
+
let pos = 0;
|
|
94
|
+
for (const line of text.split('\n')) {
|
|
95
|
+
if (/^\s{0,3}(```|~~~)/.test(line)) {
|
|
96
|
+
if (open < 0) open = pos;
|
|
97
|
+
else {
|
|
98
|
+
ranges.push([open, pos + line.length]);
|
|
99
|
+
open = -1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
pos += line.length + 1; // +1 for the '\n' consumed by split
|
|
103
|
+
}
|
|
104
|
+
// An unterminated fence runs to the end of the body — treat the remainder as code.
|
|
105
|
+
if (open >= 0) ranges.push([open, text.length]);
|
|
106
|
+
return (offset: number) => ranges.some(([start, end]) => offset >= start && offset < end);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Whether `offset` sits inside a single-backtick code span on its own line. */
|
|
110
|
+
function inInlineCodeSpan(text: string, offset: number): boolean {
|
|
111
|
+
const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
|
|
112
|
+
let ticks = 0;
|
|
113
|
+
for (let i = lineStart; i < offset; i++) if (text[i] === '`') ticks++;
|
|
114
|
+
return ticks % 2 === 1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Redact inline `https?://` tokens in synthesis prose whose URL was NOT retrieved
|
|
119
|
+
* this session (absent from `trusted`, a set of normalized verified-source URLs).
|
|
120
|
+
*
|
|
121
|
+
* The synthesis body is otherwise passed through verbatim, so an unverified inline
|
|
122
|
+
* URL is the one channel by which a fabricated source can reach the user — and
|
|
123
|
+
* inline URLs violate the required format (links belong in CITED LINKS only).
|
|
124
|
+
* Legitimate inline references to real sources are preserved (they are present in
|
|
125
|
+
* `trusted`). Returns the (possibly) redacted text.
|
|
126
|
+
*/
|
|
127
|
+
function redactUnverifiedProseUrls(text: string, trusted: Set<string>): string {
|
|
128
|
+
if (trusted.size === 0) return text;
|
|
129
|
+
let removed = 0;
|
|
130
|
+
// Brackets are NOT excluded from the token: a bare `[^\s)\]>"']+` truncates
|
|
131
|
+
// `.../Python_(programming_language)` at the first `)`, so the truncated form
|
|
132
|
+
// never matches its own entry in `trusted` and a perfectly good Wikipedia link
|
|
133
|
+
// gets redacted. Instead take the greedy token and split off exactly the
|
|
134
|
+
// trailing punctuation `normalizeUrl` itself discards — so the compared string
|
|
135
|
+
// and the redacted span always agree — restoring that tail afterwards.
|
|
136
|
+
const result = text.replace(/https?:\/\/[^\s<>"']+/gi, (token) => {
|
|
137
|
+
const core = stripTrailingLlmPunctuation(token);
|
|
138
|
+
const tail = token.slice(core.length);
|
|
139
|
+
if (!core || trusted.has(normalizeUrl(core))) return token;
|
|
140
|
+
removed++;
|
|
141
|
+
return `[link removed: not found in retrieved sources]${tail}`;
|
|
142
|
+
});
|
|
143
|
+
if (removed > 0) {
|
|
144
|
+
logger.warn(`[ResearchSynthesisService] Redacted ${removed} unverified inline URL(s) from synthesis prose (not present in retrieved sources).`);
|
|
145
|
+
}
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
|
|
29
149
|
/**
|
|
30
150
|
* Research Synthesis Service
|
|
31
151
|
*
|
|
@@ -178,6 +298,18 @@ export class ResearchSynthesisService implements IService {
|
|
|
178
298
|
let globalCitations: GlobalCitation[] =
|
|
179
299
|
reports.size > 0 ? normalizeCitations(reports).globalCitations : [];
|
|
180
300
|
|
|
301
|
+
// NOTE: a "provenance gate" previously DROPPED citations tagged Scrape/Transcript
|
|
302
|
+
// whose URL was absent from the session's successful-scrape pool. It was REMOVED
|
|
303
|
+
// because it conflated a failed/blocked scrape (real URL, content unretrieved) with
|
|
304
|
+
// fabrication: under the common high scrape-failure rate (bot blocks on GitHub /
|
|
305
|
+
// Microsoft / etc.) it mass-dropped legitimate citations, and the remap below then
|
|
306
|
+
// neutralized their inline [N] markers into a broken '[removed]' token that reached
|
|
307
|
+
// users. We now KEEP every parsed citation (deduped by URL) and never emit
|
|
308
|
+
// '[removed]'; a dangling inline marker is removed cleanly instead. Fabrication
|
|
309
|
+
// defense rests on the URL dedup + the prose-URL sweep, which do not punish failed
|
|
310
|
+
// scrapes. (The successful-scrape pool is still reconciled below to FOLD IN any
|
|
311
|
+
// sources the researchers scraped but failed to cite.)
|
|
312
|
+
|
|
181
313
|
// Robustness fallback: reconcile against ground-truth provenance — the URLs
|
|
182
314
|
// ACTUALLY, successfully scraped this session (registerScrapedLinks only records
|
|
183
315
|
// HTTP-success fetches). This runs even when SOME citations parsed: a
|
|
@@ -216,6 +348,7 @@ export class ResearchSynthesisService implements IService {
|
|
|
216
348
|
|
|
217
349
|
if (globalCitations.length > 0) {
|
|
218
350
|
const verifiedLinksSection = formatCitedLinks(globalCitations);
|
|
351
|
+
const trustedUrls = new Set(globalCitations.map((c) => normalizeUrl(c.url)));
|
|
219
352
|
// Replace an existing CITED LINKS section with the verified version — but only
|
|
220
353
|
// a line-leading, all-caps "CITED LINKS" header marks the section. An
|
|
221
354
|
// unanchored /CITED LINKS[\s\S]*$/i would also fire on an incidental
|
|
@@ -225,31 +358,48 @@ export class ResearchSynthesisService implements IService {
|
|
|
225
358
|
if (citedHeaderIdx >= 0) {
|
|
226
359
|
logger.debug('[ResearchSynthesisService] Replacing existing CITED LINKS with verified version');
|
|
227
360
|
// The verified list is renumbered (dedup by URL, implausible entries dropped),
|
|
228
|
-
// but the body's inline [N] markers were authored against the list being
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
361
|
+
// but the body's inline [N] markers were authored against the list being replaced.
|
|
362
|
+
// Remap them by URL identity — parse the synthesis's own list and map each
|
|
363
|
+
// written number to the global id of the same normalized URL. In deep mode
|
|
364
|
+
// the numbering already matches and this is a no-op; in quick mode (where the
|
|
365
|
+
// model's own list is what gets replaced) it prevents every marker after a
|
|
366
|
+
// dropped/deduped entry from pointing at the wrong source.
|
|
234
367
|
const ownCitations = parseCitations(synthesis);
|
|
235
368
|
const urlToGlobalId = new Map(globalCitations.map((c) => [normalizeUrl(c.url), c.id]));
|
|
236
369
|
const localToGlobal = new Map<number, number>();
|
|
370
|
+
// own-citation numbers whose URL is absent from the verified list. Their inline
|
|
371
|
+
// [N] markers are REMOVED cleanly (never '[removed]') so the prose reads
|
|
372
|
+
// naturally. A marker can dangle only if the lead cited a URL absent from the
|
|
373
|
+
// rebuilt list (e.g. it invented one not in any researcher report); removing it
|
|
374
|
+
// beats shipping a broken token or silently retargeting it at the wrong source.
|
|
375
|
+
const droppedNumbers = new Set<number>();
|
|
237
376
|
ownCitations.forEach((cit, index) => {
|
|
377
|
+
const localKey = cit.number ?? index + 1;
|
|
238
378
|
const globalId = urlToGlobalId.get(normalizeUrl(cit.url));
|
|
239
|
-
if (globalId !== undefined) localToGlobal.set(
|
|
379
|
+
if (globalId !== undefined) localToGlobal.set(localKey, globalId);
|
|
380
|
+
else droppedNumbers.add(localKey);
|
|
240
381
|
});
|
|
241
382
|
let body = synthesis.slice(0, citedHeaderIdx).trimEnd();
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
body = body.replace(/\[(\d+)\]/g, (match, p1) => {
|
|
245
|
-
const globalId = localToGlobal.get(parseInt(p1, 10));
|
|
246
|
-
return globalId !== undefined ? `[${globalId}]` : match;
|
|
247
|
-
});
|
|
383
|
+
if (localToGlobal.size > 0 || droppedNumbers.size > 0) {
|
|
384
|
+
body = rewriteCitationMarkers(body, localToGlobal, (n) => droppedNumbers.has(n));
|
|
248
385
|
}
|
|
386
|
+
// Defense-in-depth: redact unverified inline URLs in the prose body. The body
|
|
387
|
+
// is otherwise passed through verbatim, so this is the one channel by which a
|
|
388
|
+
// fabricated source can still reach the user; inline URLs also violate the
|
|
389
|
+
// required format. Verified inline references (in `trustedUrls`) are kept.
|
|
390
|
+
body = redactUnverifiedProseUrls(body, trustedUrls);
|
|
249
391
|
return `${body}\n\n${verifiedLinksSection}`;
|
|
250
392
|
}
|
|
251
393
|
logger.warn('[ResearchSynthesisService] Synthesis missing CITED LINKS - appending verified version');
|
|
252
|
-
|
|
394
|
+
// No existing CITED LINKS header: the synthesis is all prose. The model's
|
|
395
|
+
// inline [N] markers were authored without a list we could remap against, so
|
|
396
|
+
// REMOVE any that fall outside the verified range (1..K) — they point at sources
|
|
397
|
+
// absent from the rebuilt list — before sweeping for unverified inline URLs and
|
|
398
|
+
// appending the verified section. Removed cleanly (never '[removed]').
|
|
399
|
+
const validIds = new Set(globalCitations.map((c) => c.id));
|
|
400
|
+
let prose = rewriteCitationMarkers(synthesis.trim(), new Map(), (n) => !validIds.has(n));
|
|
401
|
+
prose = redactUnverifiedProseUrls(prose, trustedUrls);
|
|
402
|
+
return `${prose}\n\n${verifiedLinksSection}`;
|
|
253
403
|
}
|
|
254
404
|
|
|
255
405
|
// Genuinely no sources were retrieved (e.g. every search/scrape was blocked by
|
|
@@ -257,7 +407,13 @@ export class ResearchSynthesisService implements IService {
|
|
|
257
407
|
// uncited report that looks sourced — but also never destroy a CITED LINKS block
|
|
258
408
|
// the model may have written that we simply could not parse (preserve as-is).
|
|
259
409
|
if (/CITED LINKS/i.test(synthesis)) {
|
|
260
|
-
|
|
410
|
+
// Preserved, but NOT silently: reaching here means no citation parsed AND no
|
|
411
|
+
// scrape succeeded, so every link in that block is unverified by construction.
|
|
412
|
+
// Returning it untouched shipped a fully sourced-looking report with no caveat —
|
|
413
|
+
// the worst case for a reader, since an unretrievable session is precisely when
|
|
414
|
+
// a model's links are most likely to be invented.
|
|
415
|
+
logger.warn('[ResearchSynthesisService] Preserving an unparseable CITED LINKS block from a run that retrieved no sources — flagging it as unverified.');
|
|
416
|
+
return `${synthesis.trimEnd()}\n\n${UNVERIFIED_LINKS_NOTE}`;
|
|
261
417
|
}
|
|
262
418
|
logger.warn('[ResearchSynthesisService] No citations and no scrape provenance — appending explicit no-sources note.');
|
|
263
419
|
return `${synthesis.trim()}\n\n${NO_SOURCES_NOTE}`;
|
|
@@ -39,9 +39,15 @@ export interface RetryOptions {
|
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* Sleep for `ms`, resolving early (rejecting with an AbortError) if `signal`
|
|
42
|
-
* aborts. The timer is unref'd so it never keeps the event loop alive.
|
|
42
|
+
* aborts. The timer is unref'd by default so it never keeps the event loop alive.
|
|
43
|
+
*
|
|
44
|
+
* Pass `keepAlive` when the sleep is *foreground* work — i.e. the caller is
|
|
45
|
+
* mid-operation and the operation must still complete after the delay. An unref'd
|
|
46
|
+
* timer is only safe while some other referenced handle exists; if the delay is the
|
|
47
|
+
* sole pending handle, Node drains the loop and exits mid-operation, silently
|
|
48
|
+
* truncating the work instead of resuming it.
|
|
43
49
|
*/
|
|
44
|
-
export function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
50
|
+
export function abortableDelay(ms: number, signal?: AbortSignal, keepAlive = false): Promise<void> {
|
|
45
51
|
return new Promise<void>((resolve, reject) => {
|
|
46
52
|
if (signal?.aborted) {
|
|
47
53
|
reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }));
|
|
@@ -51,7 +57,7 @@ export function abortableDelay(ms: number, signal?: AbortSignal): Promise<void>
|
|
|
51
57
|
if (signal && onAbort) signal.removeEventListener('abort', onAbort);
|
|
52
58
|
resolve();
|
|
53
59
|
}, ms);
|
|
54
|
-
(timeoutId as TimeoutHandle).unref?.();
|
|
60
|
+
if (!keepAlive) (timeoutId as TimeoutHandle).unref?.();
|
|
55
61
|
const onAbort = signal
|
|
56
62
|
? () => {
|
|
57
63
|
clearTimeout(timeoutId as unknown as ReturnType<typeof setTimeout>);
|
|
@@ -264,6 +264,49 @@ export async function getSsrfSafeDispatcher(): Promise<unknown | null> {
|
|
|
264
264
|
return cachedSsrfDispatcher as unknown | null;
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
/**
|
|
268
|
+
* Render an error for diagnostics, surfacing the swallowed underlying cause.
|
|
269
|
+
*
|
|
270
|
+
* Node's `fetch()`/undici wraps the real transport reason (ECONNRESET, ENOTFOUND,
|
|
271
|
+
* a TLS error, UND_ERR_*, an AbortError) under `error.cause`, and the top-level
|
|
272
|
+
* object is almost always the uninformative `TypeError: fetch failed`. Logging
|
|
273
|
+
* only `String(err)` (or `err.message`) therefore records "fetch failed" and
|
|
274
|
+
* discards the actual reason — which made the production scrape-failure spike
|
|
275
|
+
* (212+ sub-5ms failures) impossible to diagnose from logs. This walks the
|
|
276
|
+
* `cause` chain (bounded, mirroring `isTransientError`) and folds in any Node /
|
|
277
|
+
* undici `.code`, so each "fetch failed" log line is self-diagnosing.
|
|
278
|
+
*
|
|
279
|
+
* Defensive against throwing getters and non-Error values: never throws.
|
|
280
|
+
*/
|
|
281
|
+
export function formatErrorWithCause(err: unknown): string {
|
|
282
|
+
try {
|
|
283
|
+
const parts: string[] = [];
|
|
284
|
+
let current: unknown = err;
|
|
285
|
+
for (let depth = 0; current !== null && current !== undefined && depth < 5; depth++) {
|
|
286
|
+
let segment: string;
|
|
287
|
+
if (current instanceof Error) {
|
|
288
|
+
segment = current.message || current.name;
|
|
289
|
+
const code = (current as { code?: unknown }).code;
|
|
290
|
+
if (typeof code === 'string' && code && !segment.includes(code)) {
|
|
291
|
+
segment = `${segment} [${code}]`;
|
|
292
|
+
}
|
|
293
|
+
} else if (typeof current === 'string') {
|
|
294
|
+
segment = current;
|
|
295
|
+
} else {
|
|
296
|
+
segment = String(current);
|
|
297
|
+
}
|
|
298
|
+
if (segment && !parts.includes(segment)) parts.push(segment);
|
|
299
|
+
const next = (current as { cause?: unknown }).cause;
|
|
300
|
+
if (next === current) break; // defensive: self-referential cause
|
|
301
|
+
current = next;
|
|
302
|
+
}
|
|
303
|
+
if (parts.length === 0) return String(err);
|
|
304
|
+
return parts.length === 1 ? parts[0]! : parts.join(' ← ');
|
|
305
|
+
} catch {
|
|
306
|
+
return String(err);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
267
310
|
/**
|
|
268
311
|
* Check if an IPv4 address is private/reserved/loopback.
|
|
269
312
|
*/
|
|
@@ -23,12 +23,16 @@ import {
|
|
|
23
23
|
import {
|
|
24
24
|
FETCH_LAYER_TIMEOUT,
|
|
25
25
|
} from './types.ts';
|
|
26
|
-
import { getRandomUserAgent, extractDomain, validateUrlForSSRF, validateContent, createNativeMarkdownConverter, createJsMarkdownConverter, getSsrfSafeDispatcher, isBenignScrapeFailure, } from './scraper-utils.ts';
|
|
26
|
+
import { getRandomUserAgent, extractDomain, validateUrlForSSRF, validateContent, createNativeMarkdownConverter, createJsMarkdownConverter, getSsrfSafeDispatcher, formatErrorWithCause, isBenignScrapeFailure, } from './scraper-utils.ts';
|
|
27
|
+
import { isTransientError, abortableDelay } from './retry-utils.ts';
|
|
27
28
|
import { safeUnref } from '../utils/safe-unref.ts';
|
|
28
29
|
import { getServiceContainer } from '../core/service-registry.ts';
|
|
29
30
|
import type { ServiceContainer } from '../core/service-registry.ts';
|
|
30
31
|
import { cacheScrapedContent } from '../utils/shared-links.ts';
|
|
31
32
|
|
|
33
|
+
/** Backoff before the fetch layer's single transient retry. */
|
|
34
|
+
const FETCH_RETRY_DELAY_MS = 250;
|
|
35
|
+
|
|
32
36
|
let playwrightAvailable: boolean = false;
|
|
33
37
|
let markdownConverterPromise: Promise<(html: string) => Promise<string>> | null = null;
|
|
34
38
|
|
|
@@ -191,6 +195,25 @@ async function readBodyCapped(response: Response, maxBytes: number): Promise<Uin
|
|
|
191
195
|
return out;
|
|
192
196
|
}
|
|
193
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Record a fetch-layer failure exactly once per URL.
|
|
200
|
+
*
|
|
201
|
+
* Kept out of {@link scrapeWithFetch} because that function is retried: tracking
|
|
202
|
+
* inside it would file one transient failure as two, and the fetch layer's error
|
|
203
|
+
* counts are precisely what production incidents are diagnosed from.
|
|
204
|
+
*/
|
|
205
|
+
function trackFetchLayerFailure(url: string, error: unknown): void {
|
|
206
|
+
const isSsrf = error instanceof Error && error.message.includes('not allowed');
|
|
207
|
+
errorTracker.trackError(error instanceof Error ? error : String(error), {
|
|
208
|
+
component: 'scrapers',
|
|
209
|
+
operation: 'fetch',
|
|
210
|
+
url,
|
|
211
|
+
domain: extractDomain(url),
|
|
212
|
+
layer: 'fetch',
|
|
213
|
+
...(isSsrf ? { errorType: 'ssrf_blocked' } : {}),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
194
217
|
async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<ScrapeLayerResult> {
|
|
195
218
|
await validateUrlForSSRF(url);
|
|
196
219
|
|
|
@@ -313,14 +336,7 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
|
|
|
313
336
|
const sizeMB = Math.round(e.bytesRead / 1024 / 1024);
|
|
314
337
|
logger.warn(`[Scrapers] HTML response too large (streamed ${sizeMB}MB, max 25MB), skipping`);
|
|
315
338
|
metrics.increment('scrape_errors_total', 1, { error_type: 'size_exceeded', content_type: 'html' });
|
|
316
|
-
|
|
317
|
-
component: 'scrapers',
|
|
318
|
-
operation: 'fetch',
|
|
319
|
-
url,
|
|
320
|
-
domain: extractDomain(url),
|
|
321
|
-
layer: 'fetch',
|
|
322
|
-
contentType: 'html',
|
|
323
|
-
});
|
|
339
|
+
// Not tracked here — scrapeSingle tracks the fetch layer's final error once.
|
|
324
340
|
throw new Error(`HTML response too large (${sizeMB}MB, max 25MB)`, { cause: e });
|
|
325
341
|
}
|
|
326
342
|
throw e;
|
|
@@ -330,14 +346,7 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
|
|
|
330
346
|
const sizeMB = Math.round(bodyBytes.length / 1024 / 1024);
|
|
331
347
|
logger.warn(`[Scrapers] HTML response too large (actual: ${sizeMB}MB, max 25MB), skipping`);
|
|
332
348
|
metrics.increment('scrape_errors_total', 1, { error_type: 'size_exceeded', content_type: 'html' });
|
|
333
|
-
|
|
334
|
-
component: 'scrapers',
|
|
335
|
-
operation: 'fetch',
|
|
336
|
-
url,
|
|
337
|
-
domain: extractDomain(url),
|
|
338
|
-
layer: 'fetch',
|
|
339
|
-
contentType: 'html',
|
|
340
|
-
});
|
|
349
|
+
// Not tracked here — scrapeSingle tracks the fetch layer's final error once.
|
|
341
350
|
throw new Error(`HTML response too large (${sizeMB}MB, max 25MB)`);
|
|
342
351
|
}
|
|
343
352
|
|
|
@@ -356,27 +365,17 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
|
|
|
356
365
|
metrics.observe('scrape_latency_ms', fetchDuration, { layer: 'fetch', content_type: 'html', status: 'success' });
|
|
357
366
|
return { source: 'fetch', layer: 'fetch', markdown };
|
|
358
367
|
} catch (error) {
|
|
368
|
+
// Attempt-scoped metrics stay here (they count fetch operations, and a retried
|
|
369
|
+
// attempt IS a second operation). Error TRACKING does not: scrapeSingle wraps this
|
|
370
|
+
// call in a retry, and tracking per attempt would record one failure twice —
|
|
371
|
+
// inflating exactly the error counts these numbers are read to diagnose.
|
|
372
|
+
// scrapeSingle calls trackFetchLayerFailure once with the final error.
|
|
359
373
|
if (error instanceof Error && error.message.includes('not allowed')) {
|
|
360
374
|
metrics.increment('scrape_operations_total', 1, { layer: 'fetch', status: 'ssrf_blocked' });
|
|
361
375
|
metrics.observe('scrape_latency_ms', Date.now() - fetchStart, { layer: 'fetch', status: 'ssrf_blocked' });
|
|
362
|
-
errorTracker.trackError(error, {
|
|
363
|
-
component: 'scrapers',
|
|
364
|
-
operation: 'fetch',
|
|
365
|
-
url,
|
|
366
|
-
domain: extractDomain(url),
|
|
367
|
-
layer: 'fetch',
|
|
368
|
-
errorType: 'ssrf_blocked',
|
|
369
|
-
});
|
|
370
376
|
throw error;
|
|
371
377
|
}
|
|
372
378
|
metrics.increment('scrape_operations_total', 1, { layer: 'fetch', status: 'error' });
|
|
373
|
-
errorTracker.trackError(error instanceof Error ? error : String(error), {
|
|
374
|
-
component: 'scrapers',
|
|
375
|
-
operation: 'fetch',
|
|
376
|
-
url,
|
|
377
|
-
domain: extractDomain(url),
|
|
378
|
-
layer: 'fetch',
|
|
379
|
-
});
|
|
380
379
|
throw error;
|
|
381
380
|
} finally {
|
|
382
381
|
clearTimeout(timeoutId);
|
|
@@ -482,7 +481,35 @@ export async function scrapeSingle(url: string, signal?: AbortSignal, config?: C
|
|
|
482
481
|
|
|
483
482
|
const start = Date.now();
|
|
484
483
|
try {
|
|
485
|
-
|
|
484
|
+
// One cheap transient retry before falling back to the (10–30× slower) stealth
|
|
485
|
+
// browser. The fetch layer is the only scrape layer with no retry; a transient
|
|
486
|
+
// socket/DNS blip (ECONNRESET, ENOTFOUND, UND_ERR_*, a bare `fetch failed`)
|
|
487
|
+
// otherwise costs a full browser render. Gated on isTransientError, so SSRF
|
|
488
|
+
// rejections, size caps and 4xx rethrow immediately; the outer catch still owns
|
|
489
|
+
// the browser fallback on genuine exhaustion.
|
|
490
|
+
//
|
|
491
|
+
// Deliberately NOT retryWithBackoff: it logs every retry at WARN, and a
|
|
492
|
+
// fetch-layer miss is an expected, benign event with a fallback behind it —
|
|
493
|
+
// the same reason isBenignScrapeFailure keeps these out of ERROR. A burst of
|
|
494
|
+
// blocked scrapes would otherwise emit one WARN per URL.
|
|
495
|
+
const res = await (async () => {
|
|
496
|
+
try {
|
|
497
|
+
return await scrapeWithFetch(url, signal);
|
|
498
|
+
} catch (firstErr) {
|
|
499
|
+
if (!isTransientError(firstErr) || signal?.aborted) throw firstErr;
|
|
500
|
+
logger.debug(`[Scrapers] fetch transient failure for ${url}, retrying once: ${formatErrorWithCause(firstErr)}`);
|
|
501
|
+
metrics.increment('scrape_fetch_retries_total', 1, { layer: 'fetch' });
|
|
502
|
+
try {
|
|
503
|
+
// keepAlive: the retry is foreground work. An unref'd backoff is only safe
|
|
504
|
+
// while some other handle is referenced; a lone scrape would otherwise let
|
|
505
|
+
// the process exit during the 250ms gap and never make the second attempt.
|
|
506
|
+
await abortableDelay(FETCH_RETRY_DELAY_MS, signal, true);
|
|
507
|
+
} catch {
|
|
508
|
+
throw firstErr; // aborted during backoff — surface the real cause
|
|
509
|
+
}
|
|
510
|
+
return await scrapeWithFetch(url, signal);
|
|
511
|
+
}
|
|
512
|
+
})();
|
|
486
513
|
const duration = Date.now() - start;
|
|
487
514
|
logger.log(`[Scrapers] fetch success for ${url} in ${duration}ms`);
|
|
488
515
|
metrics.increment('scrape_results_total', 1, { outcome: 'fetch_success' });
|
|
@@ -494,10 +521,10 @@ export async function scrapeSingle(url: string, signal?: AbortSignal, config?: C
|
|
|
494
521
|
return { ...res, url, success: true };
|
|
495
522
|
} catch (e1) {
|
|
496
523
|
const fetchDuration = Date.now() - start;
|
|
497
|
-
logger.debug(`[Scrapers] fetch failed for ${url} in ${fetchDuration}ms: ${
|
|
498
|
-
//
|
|
499
|
-
//
|
|
500
|
-
|
|
524
|
+
logger.debug(`[Scrapers] fetch failed for ${url} in ${fetchDuration}ms: ${formatErrorWithCause(e1)}`);
|
|
525
|
+
// Tracked here rather than inside scrapeWithFetch, which is retried above —
|
|
526
|
+
// one transient failure must file one error, not one per attempt.
|
|
527
|
+
trackFetchLayerFailure(url, e1);
|
|
501
528
|
|
|
502
529
|
if (playwrightAvailable) {
|
|
503
530
|
try {
|
|
@@ -524,18 +551,18 @@ export async function scrapeSingle(url: string, signal?: AbortSignal, config?: C
|
|
|
524
551
|
// at ERROR with its stack. Either way, scrapeWithStealthBrowser's own catch
|
|
525
552
|
// already tracked the error with layer context, so it is not re-tracked here.
|
|
526
553
|
if (isBenignScrapeFailure(e2)) {
|
|
527
|
-
logger.debug(`[Scrapers] Browser fallback did not yield content for ${url} in ${totalDuration}ms: ${
|
|
554
|
+
logger.debug(`[Scrapers] Browser fallback did not yield content for ${url} in ${totalDuration}ms: ${formatErrorWithCause(e2)}`);
|
|
528
555
|
} else {
|
|
529
556
|
logger.error(`[Scrapers] Browser fallback failed for ${url} in ${totalDuration}ms:`, e2);
|
|
530
557
|
}
|
|
531
558
|
metrics.increment('scrape_errors_total', 1, { error_type: 'fallback_failed', layer: 'playwright' });
|
|
532
559
|
metrics.increment('scrape_results_total', 1, { outcome: 'total_failure' });
|
|
533
|
-
return { url, success: false, error:
|
|
560
|
+
return { url, success: false, error: formatErrorWithCause(e2), markdown: '' };
|
|
534
561
|
}
|
|
535
562
|
}
|
|
536
563
|
metrics.increment('scrape_errors_total', 1, { error_type: 'no_fallback_available', layer: 'fetch' });
|
|
537
564
|
metrics.increment('scrape_results_total', 1, { outcome: 'total_failure' });
|
|
538
|
-
return { url, success: false, error:
|
|
565
|
+
return { url, success: false, error: formatErrorWithCause(e1), markdown: '' };
|
|
539
566
|
}
|
|
540
567
|
}
|
|
541
568
|
|