@lincoln504/pi-research 1.1.2 → 1.3.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 +3 -3
- package/dist/cli.mjs +2599 -1268
- package/dist/thread-worker.mjs +224 -147
- package/docs/AGENT-SKILL.md +11 -4
- package/docs/ARCHITECTURE.md +20 -10
- package/docs/CHANGELOG.md +195 -17
- package/docs/CONFIGURATION.md +9 -7
- package/docs/KNOWLEDGE-STORE.md +4 -3
- package/docs/PI-EXTENSION.md +1 -1
- package/docs/SDK.md +55 -0
- package/docs/THIRD-PARTY-NOTICES.md +1 -1
- package/package.json +5 -4
- package/scripts/cleanup.cjs +114 -12
- package/scripts/prepare.cjs +15 -5
- package/scripts/setup.cjs +24 -4
- package/skills/pi-research/README.md +9 -2
- package/skills/pi-research/SKILL.md +6 -3
- package/skills/pi-research/references/configuration.md +6 -2
- package/skills/pi-research/scripts/run.mjs +24 -2
- package/src/cleanup/research-cleanup.ts +12 -4
- package/src/cli.ts +372 -86
- package/src/config.ts +134 -17
- package/src/constants.ts +31 -0
- package/src/core/interfaces/health-check-interfaces.ts +3 -39
- package/src/core/interfaces/knowledge-interfaces.ts +6 -1
- package/src/core/interfaces/planning-interfaces.ts +8 -0
- package/src/core/interfaces/service-names.ts +0 -1
- package/src/core/interfaces/state-manager-interfaces.ts +11 -3
- package/src/core/llm/agentic-repair.ts +14 -0
- package/src/core/pi-version.ts +136 -0
- package/src/core/planning-service.ts +43 -1
- package/src/core/service-initialization.ts +6 -14
- package/src/core/service-interfaces.ts +1 -1
- package/src/core/service-registry.ts +117 -8
- package/src/healthcheck/index.ts +7 -2
- package/src/index.ts +11 -18
- package/src/infrastructure/browser/browser-client.ts +80 -13
- package/src/infrastructure/browser/browser-error-utils.ts +6 -1
- package/src/infrastructure/browser/browser-server.ts +41 -12
- package/src/infrastructure/browser/browser-task-scheduler.ts +159 -35
- package/src/infrastructure/browser/cleanup-utils.ts +95 -9
- package/src/infrastructure/browser/client-agent.ts +6 -5
- package/src/infrastructure/browser/config.ts +14 -3
- package/src/infrastructure/browser/priority-task-queue.ts +10 -3
- package/src/infrastructure/browser/scheduler-factory.ts +44 -4
- package/src/infrastructure/browser/task-execution-service.ts +92 -28
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
- package/src/infrastructure/browser/thread-worker-messaging.ts +54 -2
- package/src/infrastructure/browser/thread-worker-types.ts +3 -1
- package/src/infrastructure/browser/thread-worker.mjs +224 -147
- package/src/infrastructure/browser/worker-pool-manager.ts +18 -5
- package/src/infrastructure/embedding/embedding-client.ts +22 -8
- package/src/infrastructure/embedding/embedding-factory.ts +61 -15
- package/src/infrastructure/embedding/embedding-server.ts +105 -11
- package/src/infrastructure/file-lock-service.ts +295 -28
- package/src/infrastructure/knowledge-store-service.ts +48 -3
- package/src/infrastructure/process-lifecycle-service.ts +134 -34
- package/src/infrastructure/research-run-semaphore.ts +37 -3
- package/src/infrastructure/state/state-backup-manager.ts +5 -7
- package/src/infrastructure/state/state-browser-api.ts +5 -2
- package/src/infrastructure/state/state-browser-manager.ts +15 -1
- package/src/infrastructure/state/state-manager-service.ts +5 -5
- package/src/infrastructure/state/state-manager.ts +34 -23
- package/src/infrastructure/types/state-types.ts +9 -0
- package/src/knowledge/chunker.ts +88 -1
- package/src/knowledge/embedder-init.ts +1 -1
- package/src/knowledge/embedder.ts +188 -46
- package/src/knowledge/index.ts +1 -1
- package/src/knowledge/store.ts +101 -21
- package/src/knowledge/webgpu-viability.ts +9 -4
- package/src/knowledge/writer-queue.ts +88 -46
- package/src/logger.ts +5 -1
- package/src/observers/research-observer-impl.ts +31 -0
- package/src/orchestration/abort-utils.ts +60 -0
- package/src/orchestration/deep-research-orchestrator.ts +25 -4
- package/src/orchestration/headless-observer.ts +8 -0
- package/src/orchestration/quick-research-orchestrator.ts +2 -2
- package/src/orchestration/research-orchestration-service.ts +65 -15
- package/src/orchestration/research-session-service.ts +37 -11
- package/src/orchestration/research-synthesis-service.ts +33 -11
- package/src/orchestration/researcher-executor.ts +100 -20
- package/src/orchestration/researcher.ts +39 -20
- package/src/orchestration/session-state.ts +11 -0
- package/src/sdk.ts +125 -19
- package/src/security/cisa-kev.ts +10 -3
- package/src/security/github-advisories.ts +18 -7
- package/src/security/index.ts +51 -30
- package/src/security/nvd-client.ts +24 -8
- package/src/security/osv-client.ts +14 -4
- package/src/skill-install/skill-installer.ts +153 -11
- package/src/stackexchange/index.ts +20 -5
- package/src/stackexchange/output/table.ts +7 -2
- package/src/stackexchange/rest-client.ts +41 -3
- package/src/tools/research-knowledge-search.ts +15 -0
- package/src/tools/research-tool-definition.ts +8 -2
- package/src/tools/scrape.ts +48 -25
- package/src/tools/search.ts +16 -2
- package/src/tools/security.ts +68 -7
- package/src/tui/research-health.ts +18 -2
- package/src/utils/atomic-replace.ts +101 -0
- package/src/utils/circuit-breaker.ts +9 -2
- package/src/utils/citation-utils.ts +3 -1
- package/src/utils/disk-space-checker.ts +11 -6
- package/src/utils/http-body.ts +176 -0
- package/src/utils/json-utils.ts +116 -76
- package/src/utils/llm-usage.ts +17 -1
- package/src/utils/log-rotation.ts +42 -10
- package/src/utils/log-utils.ts +40 -0
- package/src/utils/metrics-summary.ts +27 -9
- package/src/utils/stdio-capture.ts +63 -22
- package/src/utils/text-utils.ts +8 -1
- package/src/utils/tool-usage-tracker.ts +9 -2
- package/src/web-research/browser-search.ts +70 -5
- package/src/web-research/scraper-utils.ts +13 -0
- package/src/web-research/search.ts +25 -6
- package/src/web-research/types.ts +19 -4
- package/src/web-research/web-scraper.ts +72 -54
- package/src/youtube/transcript-client.ts +5 -1
- package/src/core/health-check-service.ts +0 -265
|
@@ -92,9 +92,12 @@ export class ResearchSynthesisService implements IService {
|
|
|
92
92
|
this.sessions.set(sessionId, reports);
|
|
93
93
|
return reports;
|
|
94
94
|
}
|
|
95
|
-
// Evict the least-recently-used session when at capacity.
|
|
96
|
-
//
|
|
97
|
-
//
|
|
95
|
+
// Evict the least-recently-used session when at capacity. Every STORE touches
|
|
96
|
+
// its session to the end (above); reads deliberately do not (peekSessionReports
|
|
97
|
+
// exists so a stray read cannot create-and-evict). A run in its read-only
|
|
98
|
+
// synthesis phase therefore stops refreshing its recency — tolerable only
|
|
99
|
+
// because reaching MAX_SESSIONS at all needs ~200 uncleaned sessions in one
|
|
100
|
+
// process (run cap 3, sessions cleared after each run), i.e. a leaking host.
|
|
98
101
|
if (this.sessions.size >= ResearchSynthesisService.MAX_SESSIONS) {
|
|
99
102
|
const oldestKey = this.sessions.keys().next().value;
|
|
100
103
|
if (oldestKey !== undefined) {
|
|
@@ -107,6 +110,25 @@ export class ResearchSynthesisService implements IService {
|
|
|
107
110
|
return reports;
|
|
108
111
|
}
|
|
109
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Read a session's reports WITHOUT creating an entry for an unknown id.
|
|
115
|
+
*
|
|
116
|
+
* The read-only accessors below must not go through `getSessionReports`: that
|
|
117
|
+
* is create-on-read, so a query for an id we have never seen inserts an empty
|
|
118
|
+
* map — and at MAX_SESSIONS that insert evicts the current head. The LRU touch
|
|
119
|
+
* protects sessions that are read or written, but an in-flight run whose
|
|
120
|
+
* researchers are all still executing (reports stored early, nothing touched
|
|
121
|
+
* since) is exactly the stalest key, so a stray read for an unknown session
|
|
122
|
+
* could evict a live run's reports and silently shrink its final synthesis.
|
|
123
|
+
*
|
|
124
|
+
* Returns a shared empty map for unknown ids — callers here only read.
|
|
125
|
+
*/
|
|
126
|
+
private peekSessionReports(sessionId: string): ReadonlyMap<string, string> {
|
|
127
|
+
return this.sessions.get(sessionId) ?? ResearchSynthesisService.EMPTY_REPORTS;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private static readonly EMPTY_REPORTS: ReadonlyMap<string, string> = new Map<string, string>();
|
|
131
|
+
|
|
110
132
|
/**
|
|
111
133
|
* Store a researcher report
|
|
112
134
|
* @param sessionId - Session identifier
|
|
@@ -121,14 +143,14 @@ export class ResearchSynthesisService implements IService {
|
|
|
121
143
|
* Get a report by ID
|
|
122
144
|
*/
|
|
123
145
|
getReport(sessionId: string, id: string): string | undefined {
|
|
124
|
-
return this.
|
|
146
|
+
return this.peekSessionReports(sessionId).get(id);
|
|
125
147
|
}
|
|
126
148
|
|
|
127
149
|
/**
|
|
128
150
|
* Get all reports
|
|
129
151
|
*/
|
|
130
152
|
getAllReports(sessionId: string): Map<string, string> {
|
|
131
|
-
return new Map(this.
|
|
153
|
+
return new Map(this.peekSessionReports(sessionId));
|
|
132
154
|
}
|
|
133
155
|
|
|
134
156
|
/**
|
|
@@ -137,7 +159,7 @@ export class ResearchSynthesisService implements IService {
|
|
|
137
159
|
getReportsForRound(sessionId: string, round: number): Map<string, string> {
|
|
138
160
|
const roundReports = new Map<string, string>();
|
|
139
161
|
const prefix = `${round}.`;
|
|
140
|
-
for (const [key, report] of this.
|
|
162
|
+
for (const [key, report] of this.peekSessionReports(sessionId).entries()) {
|
|
141
163
|
if (key.startsWith(prefix)) {
|
|
142
164
|
roundReports.set(key, report);
|
|
143
165
|
}
|
|
@@ -149,14 +171,14 @@ export class ResearchSynthesisService implements IService {
|
|
|
149
171
|
* Get total number of reports
|
|
150
172
|
*/
|
|
151
173
|
getReportCount(sessionId: string): number {
|
|
152
|
-
return this.
|
|
174
|
+
return this.peekSessionReports(sessionId).size;
|
|
153
175
|
}
|
|
154
176
|
|
|
155
177
|
/**
|
|
156
178
|
* Check if there are any reports
|
|
157
179
|
*/
|
|
158
180
|
hasReports(sessionId: string): boolean {
|
|
159
|
-
return this.
|
|
181
|
+
return this.peekSessionReports(sessionId).size > 0;
|
|
160
182
|
}
|
|
161
183
|
|
|
162
184
|
/**
|
|
@@ -177,7 +199,7 @@ export class ResearchSynthesisService implements IService {
|
|
|
177
199
|
* @returns Fallback synthesis string
|
|
178
200
|
*/
|
|
179
201
|
buildFallbackSynthesis(sessionId: string, currentRound: number = 0): string {
|
|
180
|
-
const reports = this.
|
|
202
|
+
const reports = this.peekSessionReports(sessionId);
|
|
181
203
|
const reportCount = reports.size;
|
|
182
204
|
const roundInfo = currentRound > 0 ? ` (up to Round ${currentRound})` : '';
|
|
183
205
|
let synthesis = `Research Findings${roundInfo}\n\n`;
|
|
@@ -211,7 +233,7 @@ export class ResearchSynthesisService implements IService {
|
|
|
211
233
|
* @returns Synthesis with guaranteed and verified CITED LINKS section
|
|
212
234
|
*/
|
|
213
235
|
ensureCitedLinks(sessionId: string, synthesis: string): string {
|
|
214
|
-
const reports = this.
|
|
236
|
+
const reports = this.peekSessionReports(sessionId);
|
|
215
237
|
|
|
216
238
|
// Primary source: citations the researchers wrote into their reports. These are
|
|
217
239
|
// the richest (they carry descriptions and source tags), so they win when present.
|
|
@@ -374,7 +396,7 @@ export class ResearchSynthesisService implements IService {
|
|
|
374
396
|
extractAllCitations(sessionId: string): Array<{ url: string; description: string; source?: string }> {
|
|
375
397
|
const seen = new Set<string>();
|
|
376
398
|
const allCitations: Array<{ url: string; description: string; source?: string }> = [];
|
|
377
|
-
const reports = this.
|
|
399
|
+
const reports = this.peekSessionReports(sessionId);
|
|
378
400
|
|
|
379
401
|
for (const report of reports.values()) {
|
|
380
402
|
const citations = parseCitations(report);
|
|
@@ -19,7 +19,8 @@ import { getService, tryGetServiceContainerFromCtx } from '../core/service-regis
|
|
|
19
19
|
import type { ResearchSessionService } from './research-session-service.ts';
|
|
20
20
|
import { loadPrompt } from '../core/llm/prompts.ts';
|
|
21
21
|
import { injectCurrentDate } from '../core/llm/inject-date.ts';
|
|
22
|
-
import { recordResearcherFailure, getSteeringMessages } from './session-state.ts';
|
|
22
|
+
import { recordResearcherFailure, getSteeringMessages, shouldStopResearch } from './session-state.ts';
|
|
23
|
+
import { isAbortSentinel, boundSessionAbort } from './abort-utils.ts';
|
|
23
24
|
import type { RunResearcherOptions } from './orchestration-types.ts';
|
|
24
25
|
import { search } from '../web-research/search.ts';
|
|
25
26
|
|
|
@@ -46,6 +47,15 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
|
|
|
46
47
|
|
|
47
48
|
const id = String(researcherConfig.id);
|
|
48
49
|
const container = tryGetServiceContainerFromCtx(ctx);
|
|
50
|
+
|
|
51
|
+
// Fast-stop self-check: when the run's failure threshold trips, runResearchers'
|
|
52
|
+
// fast-stop path aborts only REGISTERED sessions — a researcher that is still
|
|
53
|
+
// acquiring initial links, sleeping between attempts, or rebuilding a session
|
|
54
|
+
// has none, and would otherwise sail on into a full LLM session + scrape burst
|
|
55
|
+
// AFTER the run already returned and released its run-cap slot. Every launch /
|
|
56
|
+
// retry boundary below consults this and exits quietly instead (the stop error
|
|
57
|
+
// is the run's outcome, not this researcher's failure).
|
|
58
|
+
const stopRequested = (): boolean => shouldStopResearch(sessionId, researchId, config.MAX_FAILED_RESEARCHERS);
|
|
49
59
|
observer?.onResearcherStart?.(id, researcherConfig.name, researcherConfig.goal, round);
|
|
50
60
|
metrics.increment('researchers_launched_total', 1, { mode: 'deep', complexity: String(complexity), round: String(round) });
|
|
51
61
|
|
|
@@ -81,12 +91,12 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
|
|
|
81
91
|
const maxLinkAttempts = config.RESEARCHER_MAX_RETRIES + 1;
|
|
82
92
|
for (let linkAttempt = 1; linkAttempt <= maxLinkAttempts && effectiveInitialLinks.length === 0; linkAttempt++) {
|
|
83
93
|
if (linkAttempt > 1) {
|
|
84
|
-
if (signal?.aborted || container?.isDisposing) break;
|
|
94
|
+
if (signal?.aborted || container?.isDisposing || stopRequested()) break;
|
|
85
95
|
const delay = Math.min(1000 * Math.pow(2, linkAttempt - 2), config.RESEARCHER_MAX_RETRY_DELAY_MS);
|
|
86
96
|
logger.warn(`[ResearcherExecutor] Researcher ${id} has no initial search results; retry ${linkAttempt - 1}/${config.RESEARCHER_MAX_RETRIES} after ${delay}ms`);
|
|
87
97
|
await new Promise(r => setTimeout(r, delay));
|
|
88
98
|
}
|
|
89
|
-
if (signal?.aborted || container?.isDisposing) break;
|
|
99
|
+
if (signal?.aborted || container?.isDisposing || stopRequested()) break;
|
|
90
100
|
try {
|
|
91
101
|
const queryResults = await search(retryQueries, config, signal, undefined, container);
|
|
92
102
|
const urls = new Set<string>();
|
|
@@ -99,6 +109,17 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
|
|
|
99
109
|
}
|
|
100
110
|
}
|
|
101
111
|
|
|
112
|
+
// A cancel/teardown/fast-stop lands here with zero links because the loop
|
|
113
|
+
// above breaks on abort/disposal/stop — that is a clean stop, not a "search
|
|
114
|
+
// produced nothing" researcher failure. Return quietly (mirrors the
|
|
115
|
+
// cancellation treatment in runResearchers) instead of recording a failure,
|
|
116
|
+
// bumping the skip metric, and painting a red failed slice with a bogus root
|
|
117
|
+
// cause on a plain cancel.
|
|
118
|
+
if (signal?.aborted || container?.isDisposing || stopRequested()) {
|
|
119
|
+
logger.debug(`[ResearcherExecutor] Researcher ${id} cancelled while acquiring initial links; skipping quietly.`);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
102
123
|
if (effectiveInitialLinks.length === 0) {
|
|
103
124
|
logger.warn(`[ResearcherExecutor] Researcher ${id} has no initial search results or historical links after ${maxLinkAttempts} attempt(s); skipping.`);
|
|
104
125
|
// Use the resolved sessionId (= piSessionId), NOT the raw ctx.sessionId: when
|
|
@@ -124,11 +145,44 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
|
|
|
124
145
|
const deliveredSteeringIds = new Set<string>();
|
|
125
146
|
|
|
126
147
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
148
|
+
// Launch boundary: never build a session for a run that is already cancelled
|
|
149
|
+
// or fast-stopped. The abort sentinel keeps the wrapper's clean-cancel
|
|
150
|
+
// classification (runResearchers treats it exactly like the race's throw);
|
|
151
|
+
// the fast-stop exit is quiet — see stopRequested above.
|
|
152
|
+
if (signal?.aborted) throw new Error('Aborted');
|
|
153
|
+
if (stopRequested()) {
|
|
154
|
+
logger.debug(`[ResearcherExecutor] Researcher ${id} exiting before attempt ${attempt}: fast-stop threshold crossed.`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
127
157
|
if (attempt > 1) {
|
|
128
158
|
const delay = Math.min(1000 * Math.pow(2, attempt - 2), config.RESEARCHER_MAX_RETRY_DELAY_MS);
|
|
129
159
|
logger.warn(`[ResearcherExecutor] Researcher ${id} retry ${attempt - 1}/${config.RESEARCHER_MAX_RETRIES} after ${delay}ms`);
|
|
130
160
|
observer?.onResearcherProgress?.(id, 'retry');
|
|
131
|
-
|
|
161
|
+
// Abortable: a cancel during this backoff must wake the sleep immediately —
|
|
162
|
+
// an inert setTimeout held the loop for up to RESEARCHER_MAX_RETRY_DELAY_MS
|
|
163
|
+
// after the cancel and then re-entered a full attempt (fresh session +
|
|
164
|
+
// billed prompt). Mirrors the stagger sleep in runResearchers.
|
|
165
|
+
await new Promise<void>((resolve) => {
|
|
166
|
+
const timer = setTimeout(() => {
|
|
167
|
+
if (signal) signal.removeEventListener('abort', onAbort);
|
|
168
|
+
resolve();
|
|
169
|
+
}, delay);
|
|
170
|
+
const onAbort = () => { clearTimeout(timer); resolve(); };
|
|
171
|
+
if (signal) {
|
|
172
|
+
if (signal.aborted) onAbort();
|
|
173
|
+
else signal.addEventListener('abort', onAbort, { once: true });
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
// Re-launch boundary after the sleep: a cancel, fast-stop, or container
|
|
177
|
+
// teardown landing DURING the backoff must not re-enter a full attempt —
|
|
178
|
+
// this is exactly the window the fast-stop path cannot reach, because
|
|
179
|
+
// abortAllSessions only aborts registered sessions and a between-attempts
|
|
180
|
+
// researcher has none.
|
|
181
|
+
if (signal?.aborted) throw new Error('Aborted');
|
|
182
|
+
if (stopRequested() || container?.isDisposing) {
|
|
183
|
+
logger.debug(`[ResearcherExecutor] Researcher ${id} exiting after backoff: fast-stop or container teardown.`);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
132
186
|
}
|
|
133
187
|
|
|
134
188
|
// Generate prompt for this attempt, incorporating ALL steering messages delivered so far
|
|
@@ -229,6 +283,32 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
|
|
|
229
283
|
throw registrationError;
|
|
230
284
|
}
|
|
231
285
|
|
|
286
|
+
// Post-registration self-check: this session is now visible to
|
|
287
|
+
// abortAllSessions(), but that scan is point-in-time — a stop/cancel that
|
|
288
|
+
// landed anywhere between createResearcherSession() resolving and
|
|
289
|
+
// registerSession() actually running (the `await getService(...)` above can
|
|
290
|
+
// yield the event loop) would have missed this session entirely, and every
|
|
291
|
+
// other guard in this function only fires BEFORE session creation or
|
|
292
|
+
// BETWEEN attempts. Nothing between here and session.prompt() below awaits
|
|
293
|
+
// anything, so this single check closes the whole window: either it fires
|
|
294
|
+
// here, or the run is not stopping and prompt() is about to make this
|
|
295
|
+
// session's registration meaningful. Mirrors the top-of-loop launch
|
|
296
|
+
// boundary above exactly (signal throws, fast-stop returns quietly) —
|
|
297
|
+
// deliberately NOT container?.isDisposing: like that boundary, this is
|
|
298
|
+
// still attempt 1's launch, and a disposing container must let this
|
|
299
|
+
// attempt's own session.prompt() fail naturally into the catch block's
|
|
300
|
+
// disposal classification rather than being pre-empted here.
|
|
301
|
+
if (signal?.aborted || stopRequested()) {
|
|
302
|
+
logger.debug(`[ResearcherExecutor] Researcher ${id} exiting right after session creation: fast-stop/cancel landed before prompt() was reached.`);
|
|
303
|
+
await boundSessionAbort(
|
|
304
|
+
session.abort().catch((err) => logger.warn(`[ResearcherExecutor] Failed to abort researcher session ${id} on post-creation fast-stop:`, err)),
|
|
305
|
+
() => logger.warn(`[ResearcherExecutor] Researcher ${id} session abort did not settle within 10s; continuing cleanup`),
|
|
306
|
+
);
|
|
307
|
+
sessionService.unregisterSession(researchId, id);
|
|
308
|
+
if (signal?.aborted) throw new Error('Aborted');
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
232
312
|
let lastSteeringCheck = Date.now();
|
|
233
313
|
|
|
234
314
|
// User-supplied observer callbacks (SDK consumers) are invoked from inside
|
|
@@ -399,13 +479,19 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
|
|
|
399
479
|
logger.debug(`[ResearcherExecutor] Researcher ${id} salvage attempt failed:`, salvageErr);
|
|
400
480
|
}
|
|
401
481
|
|
|
402
|
-
// Skip retries when the run is being torn down: an aborted signal,
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
|
|
482
|
+
// Skip retries when the run is being torn down: an aborted signal, the abort
|
|
483
|
+
// sentinel, or the service container entering disposal (SIGTERM/quit mid-run,
|
|
484
|
+
// which throws "…during container disposal" from getService). The sentinel has
|
|
485
|
+
// TWO forms — the race's bare 'Aborted' above AND the '<id>: Aborted' that
|
|
486
|
+
// ensureAssistantResponse throws when a fast-stop abortAllSessions() aborted
|
|
487
|
+
// this session externally (prompt() then resolves normally) — so match it via
|
|
488
|
+
// isAbortSentinel, not exact equality: the prefixed form used to slip past
|
|
489
|
+
// this guard and RETRY a researcher the fast-stop path had just aborted.
|
|
490
|
+
// Retrying during disposal is futile — services are being destroyed — and it
|
|
491
|
+
// relaunches search bursts into a WorkerPool that dispose() is simultaneously
|
|
492
|
+
// destroying, producing a storm of "Cannot execute a task on destroying pool"
|
|
493
|
+
// errors.
|
|
494
|
+
if (signal?.aborted || isAbortSentinel(errMsg) || errMsg.includes('during container disposal') || container?.isDisposing) {
|
|
409
495
|
logger.debug(`[ResearcherExecutor] Researcher ${id} aborted or container disposing, skipping retries.`);
|
|
410
496
|
break; // Break out of the attempt loop
|
|
411
497
|
}
|
|
@@ -423,18 +509,12 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
|
|
|
423
509
|
// never settle — never let per-attempt cleanup hang the retry loop on it. The
|
|
424
510
|
// retry builds a fresh session; the abandoned abort keeps draining in the
|
|
425
511
|
// background and its session dies with the run's teardown.
|
|
426
|
-
await
|
|
512
|
+
await boundSessionAbort(
|
|
427
513
|
session.abort().catch((err) => {
|
|
428
514
|
logger.warn(`[ResearcherExecutor] Failed to abort researcher session ${id}:`, err);
|
|
429
515
|
}),
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
logger.warn(`[ResearcherExecutor] Researcher ${id} session abort did not settle within 10s; continuing cleanup`);
|
|
433
|
-
resolve();
|
|
434
|
-
}, 10_000);
|
|
435
|
-
t.unref?.();
|
|
436
|
-
}),
|
|
437
|
-
]);
|
|
516
|
+
() => logger.warn(`[ResearcherExecutor] Researcher ${id} session abort did not settle within 10s; continuing cleanup`),
|
|
517
|
+
);
|
|
438
518
|
sessionService?.unregisterSession(researchId, id);
|
|
439
519
|
|
|
440
520
|
// Restore the default thinking label now that the researcher is done.
|
|
@@ -23,6 +23,7 @@ import { logger } from '../logger.ts';
|
|
|
23
23
|
|
|
24
24
|
import { resolveResearchModel } from '../core/llm/research-model-resolver.ts';
|
|
25
25
|
import { getRuntimeForRegistry } from '../core/llm/model-registry-factory.ts';
|
|
26
|
+
import { boundSessionAbort } from './abort-utils.ts';
|
|
26
27
|
|
|
27
28
|
export interface CreateResearcherSessionOptions {
|
|
28
29
|
cwd: string;
|
|
@@ -158,27 +159,45 @@ export async function createResearcherSession(options: CreateResearcherSessionOp
|
|
|
158
159
|
thinkingLevel: config?.LLM_THINKING_LEVEL ?? 'off',
|
|
159
160
|
});
|
|
160
161
|
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
162
|
+
// The session above is LIVE from here on, but the caller only receives it at
|
|
163
|
+
// the return below — a throw anywhere in this tail (a host UI callback, the
|
|
164
|
+
// validity check) would rethrow a session nobody owns, leaking it unaborted.
|
|
165
|
+
// Abort it best-effort (bounded: session.abort() awaits the in-flight call it
|
|
166
|
+
// cancels and may never settle) before rethrowing.
|
|
167
|
+
try {
|
|
168
|
+
// Customize the agent thinking label for researchers (e.g. "Researcher 1.1")
|
|
169
|
+
// so non-TUI surfaces can tell concurrent researchers apart. Skip this in the
|
|
170
|
+
// research TUI (mode === 'tui'): the panel already shows every researcher, and
|
|
171
|
+
// the host renders this label as dim/italic thinking text ABOVE the panel —
|
|
172
|
+
// stray output the panel was meant to replace. Use the researcherId passed by
|
|
173
|
+
// the caller (not regex on the prompt, since the ID is never templated in).
|
|
174
|
+
if (extensionCtx.mode !== 'tui' && extensionCtx.hasUI && typeof extensionCtx.ui.setHiddenThinkingLabel === 'function') {
|
|
175
|
+
extensionCtx.ui.setHiddenThinkingLabel(
|
|
176
|
+
researcherId ? `Researcher ${researcherId}` : undefined
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
logger.log(`[Researcher] Created session with model=${modelToUse?.id || 'unknown'}`);
|
|
181
|
+
|
|
182
|
+
if (!result || !result.session) {
|
|
183
|
+
throw new Error('Session creation returned invalid result');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
sessionRef.session = result.session;
|
|
187
|
+
|
|
188
|
+
return { session: result.session, resolvedModel: modelToUse };
|
|
189
|
+
} catch (tailError) {
|
|
190
|
+
const session = result?.session;
|
|
191
|
+
if (session && typeof session.abort === 'function') {
|
|
192
|
+
await boundSessionAbort(
|
|
193
|
+
(async () => session.abort())().catch((abortErr) => {
|
|
194
|
+
logger.warn('[Researcher] Failed to abort never-returned session:', abortErr);
|
|
195
|
+
}),
|
|
196
|
+
() => logger.warn('[Researcher] Never-returned session abort did not settle within bound; continuing'),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
throw tailError;
|
|
171
200
|
}
|
|
172
|
-
|
|
173
|
-
logger.log(`[Researcher] Created session with model=${modelToUse?.id || 'unknown'}`);
|
|
174
|
-
|
|
175
|
-
if (!result || !result.session) {
|
|
176
|
-
throw new Error('Session creation returned invalid result');
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
sessionRef.session = result.session;
|
|
180
|
-
|
|
181
|
-
return { session: result.session, resolvedModel: modelToUse };
|
|
182
201
|
} catch (error) {
|
|
183
202
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
184
203
|
throw new Error(`Failed to create researcher session: ${errorMsg}`, {
|
|
@@ -520,6 +520,17 @@ export function endResearchSession(piSessionId: string | undefined, researchId:
|
|
|
520
520
|
// but preserve steering messages if any remain (they might arrive between
|
|
521
521
|
// the last research end and the next research start)
|
|
522
522
|
if (state.order.length === 0 && state.panels.size === 0) {
|
|
523
|
+
// Drop steering messages this run already consumed ('active') — and any
|
|
524
|
+
// 'popped' remnants, which are likewise already resolved — before deciding
|
|
525
|
+
// whether to keep the session alive. Without this, a message the run that
|
|
526
|
+
// just ended already consumed stayed in the array and leaked into a later,
|
|
527
|
+
// unrelated run's steering (both getActiveSteeringMessages/getSteeringMessages
|
|
528
|
+
// reads and the before_agent_start injection gate), since nothing distinguishes
|
|
529
|
+
// "steering for THIS run" from "steering already applied to a finished run".
|
|
530
|
+
// Only messages still 'queued' (arrived in the gap, never attached to any run)
|
|
531
|
+
// are genuinely eligible for the gap-preservation this block performs.
|
|
532
|
+
state.steeringMessages = state.steeringMessages.filter(m => m.status === 'queued');
|
|
533
|
+
|
|
523
534
|
// Only fully delete if there are no remaining steering messages
|
|
524
535
|
const hasRemainingSteering = state.steeringMessages.length > 0;
|
|
525
536
|
if (!hasRemainingSteering) {
|
package/src/sdk.ts
CHANGED
|
@@ -28,6 +28,9 @@ import type {
|
|
|
28
28
|
ResearchOptions
|
|
29
29
|
} from './core/interfaces/orchestration-interfaces.ts';
|
|
30
30
|
import type { IPlanningService } from './core/interfaces/planning-interfaces.ts';
|
|
31
|
+
import type { ResearchObserver, HeadlessObserverOptions } from './core/interfaces/observer-interfaces.ts';
|
|
32
|
+
import type { ResearchPlan } from './core/interfaces/research-plan-types.ts';
|
|
33
|
+
import { HeadlessObserver } from './orchestration/headless-observer.ts';
|
|
31
34
|
import type { Model } from '@earendil-works/pi-ai';
|
|
32
35
|
import { type ExtensionContext, ModelRegistry } from '@earendil-works/pi-coding-agent';
|
|
33
36
|
import { logger, createLogger, setLogger } from './logger.ts';
|
|
@@ -94,6 +97,13 @@ export interface ResearcherOutcome {
|
|
|
94
97
|
failureReasons: Record<string, string>;
|
|
95
98
|
}
|
|
96
99
|
let _lastResearcherOutcome: ResearcherOutcome | null = null;
|
|
100
|
+
// Monotonic SDK-lifetime counter, bumped by _doShutdown. A run left in flight across
|
|
101
|
+
// shutdownResearchSDK() settles AFTER the shutdown wiped the `_last*` accessors and
|
|
102
|
+
// cleared session metrics; its catch/finally would then repopulate them, so a
|
|
103
|
+
// re-initialized SDK reported a phantom run from the previous lifetime. Each run
|
|
104
|
+
// captures the generation at start and every post-run telemetry write is gated on it
|
|
105
|
+
// still matching.
|
|
106
|
+
let _sdkGeneration = 0;
|
|
97
107
|
|
|
98
108
|
// Signal handler state — registered once, removed on clean shutdown.
|
|
99
109
|
let _onSignal: ((signal: string) => void) | null = null;
|
|
@@ -489,6 +499,18 @@ export async function runDeepResearch(
|
|
|
489
499
|
// here instead of vanishing into the never-read global tracker.
|
|
490
500
|
const runTracker = new ErrorTracker();
|
|
491
501
|
|
|
502
|
+
// Stamp the run with the current shutdown generation: if shutdownResearchSDK()
|
|
503
|
+
// completes while this run is still in flight, _doShutdown bumps the generation
|
|
504
|
+
// and every telemetry write below is skipped — the shutdown wipe must win over
|
|
505
|
+
// an older-generation run settling late (see _sdkGeneration).
|
|
506
|
+
const runGeneration = _sdkGeneration;
|
|
507
|
+
|
|
508
|
+
// Planned-researcher count, captured DURING the run via the observer wrapper —
|
|
509
|
+
// it is unreadable from the planning service once runResearch() settles (the deep
|
|
510
|
+
// orchestrator clears that per-run state in its own finally first).
|
|
511
|
+
let plannedResearchers = 0;
|
|
512
|
+
const observer = wrapObserverForOutcomeCapture(options.observer, (count) => { plannedResearchers += count; });
|
|
513
|
+
|
|
492
514
|
try {
|
|
493
515
|
// Resolve the orchestrator INSIDE the try: getService() can throw (container
|
|
494
516
|
// disposal race with shutdown, or an unregistered service), and if that throw
|
|
@@ -506,6 +528,9 @@ export async function runDeepResearch(
|
|
|
506
528
|
depth: depth as ResearchDepth,
|
|
507
529
|
complexity: complexity as 1 | 2 | 3,
|
|
508
530
|
initialLinks: options.initialLinks,
|
|
531
|
+
// The caller's observer, wrapped so the SDK captures the planned-researcher
|
|
532
|
+
// events for getLastResearcherOutcome() (must land AFTER the options spread).
|
|
533
|
+
observer,
|
|
509
534
|
// Forward the SDK's resolved config so initResearchSDK({ config }) overrides
|
|
510
535
|
// actually reach the orchestrator and every downstream service. A per-call
|
|
511
536
|
// options.config (if provided) takes precedence over the SDK-global config.
|
|
@@ -522,27 +547,40 @@ export async function runDeepResearch(
|
|
|
522
547
|
// unbounded across calls (a long-lived SDK/benchmark process). runRegistry.observe() lands
|
|
523
548
|
// it in this run's snapshot, matching the tool path's run-scoped emission.
|
|
524
549
|
runRegistry.observe('research_manager_latency_ms', completedAt - researchStart, { depth: depthLabel, status: 'success', source: 'sdk' });
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
550
|
+
// Generation guard: skip every module-level telemetry write if a shutdown
|
|
551
|
+
// completed mid-run — the wipe must not be resurrected by a stale run.
|
|
552
|
+
if (runGeneration === _sdkGeneration) {
|
|
553
|
+
_lastRunSummary = {
|
|
554
|
+
runId: researchId, startedAt: researchStart, completedAt,
|
|
555
|
+
durationMs: completedAt - researchStart, status: 'success', snapshot: runRegistry.getSnapshot(),
|
|
556
|
+
};
|
|
557
|
+
metrics.recordRunSummary(_lastRunSummary);
|
|
558
|
+
_lastErrorReport = runTracker.getReport();
|
|
559
|
+
logRunErrorSummary(_lastErrorReport, depthLabel, 'success');
|
|
560
|
+
// Re-check the generation AFTER the await: the gate above was evaluated
|
|
561
|
+
// before it, so a shutdown completing during the capture would otherwise
|
|
562
|
+
// land this assignment after the wipe — a phantom outcome from a dead run.
|
|
563
|
+
const outcome = await captureResearcherOutcome(sessionId, researchId, _lastRunSummary.snapshot, plannedResearchers);
|
|
564
|
+
if (runGeneration === _sdkGeneration) _lastResearcherOutcome = outcome;
|
|
565
|
+
}
|
|
533
566
|
return result;
|
|
534
567
|
} catch (err) {
|
|
535
568
|
const completedAt = Date.now();
|
|
536
569
|
// Per-run scope (see the success path above) — avoids unbounded session-scope accumulation.
|
|
537
570
|
runRegistry.observe('research_manager_latency_ms', completedAt - researchStart, { depth: depthLabel, status: 'error', source: 'sdk' });
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
571
|
+
// Generation guard — see the success path above.
|
|
572
|
+
if (runGeneration === _sdkGeneration) {
|
|
573
|
+
_lastRunSummary = {
|
|
574
|
+
runId: researchId, startedAt: researchStart, completedAt,
|
|
575
|
+
durationMs: completedAt - researchStart, status: 'error', snapshot: runRegistry.getSnapshot(),
|
|
576
|
+
};
|
|
577
|
+
metrics.recordRunSummary(_lastRunSummary);
|
|
578
|
+
_lastErrorReport = runTracker.getReport();
|
|
579
|
+
logRunErrorSummary(_lastErrorReport, depthLabel, 'error');
|
|
580
|
+
// Re-check after the await — see the success path above.
|
|
581
|
+
const outcome = await captureResearcherOutcome(sessionId, researchId, _lastRunSummary.snapshot, plannedResearchers);
|
|
582
|
+
if (runGeneration === _sdkGeneration) _lastResearcherOutcome = outcome;
|
|
583
|
+
}
|
|
546
584
|
throw err;
|
|
547
585
|
} finally {
|
|
548
586
|
// Free the per-run PiSessionState that shouldStopResearch()/getFailedResearchers()
|
|
@@ -603,16 +641,73 @@ function logRunErrorSummary(report: ErrorReport | null, depthLabel: string, stat
|
|
|
603
641
|
logger.warn(`[SDK] run ${status} with ${report.totalErrors} tracked error(s) across ${report.uniquePatterns} pattern(s) [${depthLabel}]${top ? ` — top: ${top}` : ''}`);
|
|
604
642
|
}
|
|
605
643
|
|
|
644
|
+
/**
|
|
645
|
+
* Wrap the caller's observer so the SDK sees the planned-researcher events the deep
|
|
646
|
+
* orchestrator emits DURING the run. The count cannot be read back afterwards: the
|
|
647
|
+
* orchestrator's own finally runs cleanupResearchServices → clearPlanningState(researchId)
|
|
648
|
+
* BEFORE runResearch() resolves for the SDK, so a post-run getTotalResearchersPlanned()
|
|
649
|
+
* always sees 0. onPlanningSuccess (round 1) and onEvaluationDecision('delegate', …)
|
|
650
|
+
* (later rounds) fire at exactly the points the orchestrator increments that counter —
|
|
651
|
+
* it suppresses the delegate decision for a round-cap delegation it discards, so the
|
|
652
|
+
* captured total only counts researchers that were actually going to run.
|
|
653
|
+
*
|
|
654
|
+
* An { onProgress } options bag is normalized into a HeadlessObserver HERE rather than
|
|
655
|
+
* left to the orchestrator (which uses the same shape check): if the orchestrator wrapped
|
|
656
|
+
* the bag itself, the planning events would dispatch on ITS HeadlessObserver instance and
|
|
657
|
+
* never reach this capture layer. Mirrors makeSafeObserver's empty-target Proxy so a
|
|
658
|
+
* frozen caller observer can never trip the Proxy own-property invariant.
|
|
659
|
+
*/
|
|
660
|
+
function wrapObserverForOutcomeCapture(
|
|
661
|
+
observer: ResearchObserver | HeadlessObserverOptions | undefined,
|
|
662
|
+
onPlanned: (count: number) => void,
|
|
663
|
+
): ResearchObserver {
|
|
664
|
+
const base: ResearchObserver | undefined =
|
|
665
|
+
observer && typeof (observer as HeadlessObserverOptions).onProgress === 'function' && !(observer instanceof HeadlessObserver)
|
|
666
|
+
? new HeadlessObserver(observer as HeadlessObserverOptions)
|
|
667
|
+
: (observer as ResearchObserver | undefined);
|
|
668
|
+
|
|
669
|
+
return new Proxy({} as ResearchObserver, {
|
|
670
|
+
get(_target, prop) {
|
|
671
|
+
if (prop === 'onPlanningSuccess') {
|
|
672
|
+
return (plan: ResearchPlan) => {
|
|
673
|
+
onPlanned(plan?.researchers?.length ?? 0);
|
|
674
|
+
base?.onPlanningSuccess?.(plan);
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
if (prop === 'onEvaluationDecision') {
|
|
678
|
+
return (action: 'synthesize' | 'delegate', plan?: ResearchPlan, round?: number) => {
|
|
679
|
+
if (action === 'delegate') onPlanned(plan?.researchers?.length ?? 0);
|
|
680
|
+
base?.onEvaluationDecision?.(action, plan, round);
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
if (!base) return undefined;
|
|
684
|
+
const value = Reflect.get(base, prop, base);
|
|
685
|
+
return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(base) : value;
|
|
686
|
+
},
|
|
687
|
+
has(_target, prop) {
|
|
688
|
+
if (prop === 'onPlanningSuccess' || prop === 'onEvaluationDecision') return true;
|
|
689
|
+
return base ? Reflect.has(base, prop) : false;
|
|
690
|
+
},
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
|
|
606
694
|
/**
|
|
607
695
|
* Capture how many researchers were planned/launched/failed for this run, BEFORE
|
|
608
696
|
* the caller's `finally` block frees the PiSessionState (endResearchSession) that
|
|
609
697
|
* getFailedResearchers()/getResearcherFailureReasons() read from. Best-effort:
|
|
610
698
|
* this is instrumentation only and must never affect the run's own result.
|
|
611
699
|
*/
|
|
612
|
-
async function captureResearcherOutcome(sessionId: string, researchId: string, snapshot: IMetricsSnapshot): Promise<ResearcherOutcome | null> {
|
|
700
|
+
async function captureResearcherOutcome(sessionId: string, researchId: string, snapshot: IMetricsSnapshot, plannedFromObserver: number): Promise<ResearcherOutcome | null> {
|
|
613
701
|
try {
|
|
614
|
-
|
|
615
|
-
|
|
702
|
+
// Deep runs: the observer-captured count is authoritative — the orchestrator's
|
|
703
|
+
// finally already cleared the planning service's per-run entry by the time the SDK
|
|
704
|
+
// regains control (see wrapObserverForOutcomeCapture). The service read stays as a
|
|
705
|
+
// fallback for any path that never emits the planning events.
|
|
706
|
+
let planned = plannedFromObserver;
|
|
707
|
+
if (planned === 0) {
|
|
708
|
+
const planningService = await getService<IPlanningService>(ServiceNames.PLANNING, undefined, globalContainer!);
|
|
709
|
+
planned = planningService.getTotalResearchersPlanned(researchId);
|
|
710
|
+
}
|
|
616
711
|
const launched = extractRunStats(snapshot)?.researchersLaunched ?? 0;
|
|
617
712
|
const failed = getFailedResearchers(sessionId, researchId);
|
|
618
713
|
return {
|
|
@@ -825,6 +920,14 @@ export async function shutdownResearchSDK(): Promise<void> {
|
|
|
825
920
|
async function _doShutdown(): Promise<void> {
|
|
826
921
|
logger.log('[SDK] Shutting down Research SDK...');
|
|
827
922
|
|
|
923
|
+
// Invalidate the pending telemetry writes of any run still in flight FIRST —
|
|
924
|
+
// before runCleanup and the other long awaits below. The invariant: a run
|
|
925
|
+
// settling at ANY point after shutdown begins fails the generation gate. Bumping
|
|
926
|
+
// at the end instead left the whole teardown (cleanup, clearSession, service
|
|
927
|
+
// disposal) as a window in which a settling run passed the gate and wrote a
|
|
928
|
+
// phantom run-summary into the just-cleared session metrics.
|
|
929
|
+
_sdkGeneration++;
|
|
930
|
+
|
|
828
931
|
const errors: Error[] = [];
|
|
829
932
|
|
|
830
933
|
try {
|
|
@@ -886,6 +989,9 @@ async function _doShutdown(): Promise<void> {
|
|
|
886
989
|
_lastRunSummary = null;
|
|
887
990
|
_lastErrorReport = null;
|
|
888
991
|
_lastResearcherOutcome = null;
|
|
992
|
+
// (No generation bump here — it happened at the TOP of this function, so any
|
|
993
|
+
// run settling during the teardown above already failed the gate and could not
|
|
994
|
+
// repopulate the `_last*` accessors or session metrics being wiped.)
|
|
889
995
|
// The module-level config cache survives the globals above. Without this, a
|
|
890
996
|
// shutdown → env/config-file change → re-init cycle in one process silently
|
|
891
997
|
// reuses the first init's config. (The CLI/skill are unaffected — they pass
|
package/src/security/cisa-kev.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { Vulnerability, CisaKevResult } from './types.ts';
|
|
|
10
10
|
import { createTimeoutSignal, retryWithBackoff, isTransientError } from '../web-research/retry-utils.ts';
|
|
11
11
|
import { CircuitBreaker } from '../utils/circuit-breaker.ts';
|
|
12
12
|
import { metrics } from '../utils/metrics.ts';
|
|
13
|
+
import { readJsonCapped, BodyTooLargeError, MAX_BULK_JSON_BODY_BYTES } from '../utils/http-body.ts';
|
|
13
14
|
|
|
14
15
|
const CISA_KEV_URL = 'https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json';
|
|
15
16
|
|
|
@@ -183,10 +184,16 @@ export async function searchCisaKev(
|
|
|
183
184
|
|
|
184
185
|
let data: unknown;
|
|
185
186
|
try {
|
|
186
|
-
|
|
187
|
-
|
|
187
|
+
// The KEV feed is the whole catalogue in one document — legitimately tens of
|
|
188
|
+
// megabytes and growing — so it reads against the bulk cap, not the default.
|
|
189
|
+
data = await readJsonCapped(response, MAX_BULK_JSON_BODY_BYTES);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
if (err instanceof BodyTooLargeError) {
|
|
192
|
+
metrics.increment('cisa_kev_errors_total', 1, { error_type: 'body_too_large' });
|
|
193
|
+
throw err;
|
|
194
|
+
}
|
|
188
195
|
metrics.increment('cisa_kev_errors_total', 1, { error_type: 'malformed_json' });
|
|
189
|
-
throw new Error('CISA KEV feed returned a malformed JSON response.');
|
|
196
|
+
throw new Error('CISA KEV feed returned a malformed JSON response.', { cause: err });
|
|
190
197
|
}
|
|
191
198
|
|
|
192
199
|
// CISA KEV format is an array of vulnerability objects or nested in a response object
|