@lincoln504/pi-research 1.3.12 → 1.4.3
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 +2 -2
- package/dist/cli.mjs +5670 -4705
- package/dist/prompts/research-tool-usage.md +1 -1
- package/dist/prompts/researcher.md +1 -0
- package/dist/prompts/system-lead-router.md +122 -0
- package/dist/prompts/system-lead-synthesizer.md +46 -0
- package/dist/thread-worker.mjs +169 -62
- package/docs/ARCHITECTURE.md +18 -10
- package/docs/CHANGELOG.md +225 -0
- package/docs/CONFIGURATION.md +106 -7
- package/package.json +2 -2
- package/scripts/build.cjs +8 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +5 -1
- package/src/config.ts +22 -12
- package/src/constants.ts +1 -1
- package/src/core/interfaces/observer-interfaces.ts +3 -3
- package/src/core/interfaces/orchestration-interfaces.ts +2 -0
- package/src/core/interfaces/planning-interfaces.ts +7 -1
- package/src/core/interfaces/scheduler-interfaces.ts +18 -2
- package/src/core/llm/agentic-repair.ts +7 -2
- package/src/core/llm/inject-date.ts +4 -1
- package/src/core/llm/llm-utils.ts +7 -0
- package/src/core/planning-service.ts +766 -91
- package/src/core/planning-utils.ts +21 -5
- package/src/core/service-initialization.ts +19 -3
- package/src/healthcheck/index.ts +11 -2
- package/src/index.ts +4 -3
- package/src/infrastructure/browser/browser-client.ts +68 -34
- package/src/infrastructure/browser/browser-task-scheduler.ts +236 -43
- package/src/infrastructure/browser/config.ts +79 -1
- package/src/infrastructure/browser/priority-task-queue.ts +55 -1
- package/src/infrastructure/browser/scheduler-factory.ts +12 -1
- package/src/infrastructure/browser/task-execution-service.ts +5 -5
- package/src/infrastructure/browser/thread-worker-browser.ts +87 -36
- package/src/infrastructure/browser/thread-worker-messaging.ts +99 -10
- package/src/infrastructure/browser/thread-worker.mjs +169 -62
- package/src/infrastructure/browser/thread-worker.ts +13 -5
- package/src/infrastructure/browser/worker-pool-manager.ts +127 -12
- package/src/infrastructure/embedding/embedding-client.ts +14 -4
- package/src/infrastructure/embedding/embedding-factory.ts +83 -15
- package/src/infrastructure/embedding/embedding-server.ts +34 -0
- package/src/infrastructure/file-lock-service.ts +214 -19
- package/src/infrastructure/knowledge-store-service.ts +14 -0
- package/src/infrastructure/research-run-semaphore.ts +1 -1
- package/src/infrastructure/service-initialization.ts +7 -0
- package/src/knowledge/embedder-init.ts +46 -19
- package/src/knowledge/embedder.ts +11 -3
- package/src/knowledge/index.ts +36 -10
- package/src/knowledge/store.ts +29 -5
- package/src/logger.ts +11 -0
- package/src/orchestration/deep-research-orchestrator.ts +143 -87
- package/src/orchestration/quick-research-orchestrator.ts +11 -2
- package/src/orchestration/research-orchestration-service.ts +8 -0
- package/src/orchestration/research-synthesis-service.ts +53 -1
- package/src/orchestration/researcher-executor.ts +53 -2
- package/src/orchestration/researcher.ts +15 -2
- package/src/orchestration/session-state.ts +16 -0
- package/src/prompts/research-tool-usage.md +1 -1
- package/src/prompts/researcher.md +1 -0
- package/src/prompts/system-lead-router.md +122 -0
- package/src/prompts/system-lead-synthesizer.md +46 -0
- package/src/sdk.ts +10 -0
- package/src/skill-install/skill-installer.ts +5 -1
- package/src/stackexchange/index.ts +3 -0
- package/src/stackexchange/rest-client.ts +7 -12
- package/src/tools/research-knowledge-search.ts +11 -3
- package/src/tools/research-tool-definition.ts +38 -7
- package/src/tools/scrape.ts +6 -0
- package/src/tools/search.ts +7 -0
- package/src/tools/security.ts +31 -10
- package/src/tools/stackexchange.ts +13 -7
- package/src/tools/youtube-transcript.ts +26 -12
- package/src/utils/cancellation.ts +36 -0
- package/src/utils/coverage-digest.ts +204 -0
- package/src/utils/host-config.ts +12 -1
- package/src/utils/llm-usage.ts +33 -6
- package/src/utils/log-utils.ts +10 -0
- package/src/utils/metrics-summary.ts +20 -4
- package/src/utils/metrics.ts +27 -1
- package/src/web-research/browser-search.ts +102 -22
- package/src/web-research/web-scraper.ts +27 -16
- package/src/youtube/potoken.ts +8 -1
- package/src/youtube/transcript-client.ts +56 -5
- package/dist/prompts/system-lead-evaluator.md +0 -118
- package/src/prompts/system-lead-evaluator.md +0 -118
package/dist/thread-worker.mjs
CHANGED
|
@@ -71,6 +71,8 @@ function redactSecrets(message) {
|
|
|
71
71
|
}
|
|
72
72
|
if (out.length > MAX_LOG_MESSAGE_LENGTH) {
|
|
73
73
|
out = `${out.slice(0, keep)}\u2026[truncated ${Math.max(rawLength, out.length) - keep} chars]`;
|
|
74
|
+
} else if (rawLength > scanEnd) {
|
|
75
|
+
out = `${out}\u2026[truncated ${rawLength - scanEnd} chars]`;
|
|
74
76
|
}
|
|
75
77
|
return out;
|
|
76
78
|
}
|
|
@@ -247,7 +249,7 @@ var init_error_tracker = __esm({
|
|
|
247
249
|
* @param error - The error to track (Error object, string message, or unknown)
|
|
248
250
|
* @param context - Optional context information about where the error occurred
|
|
249
251
|
*/
|
|
250
|
-
trackError(error,
|
|
252
|
+
trackError(error, context = {}) {
|
|
251
253
|
let message;
|
|
252
254
|
if (error instanceof Error) {
|
|
253
255
|
message = error.message;
|
|
@@ -276,15 +278,15 @@ var init_error_tracker = __esm({
|
|
|
276
278
|
}
|
|
277
279
|
pattern.count++;
|
|
278
280
|
pattern.lastSeen = now;
|
|
279
|
-
if (
|
|
280
|
-
const existing = pattern.domainCounts.get(
|
|
281
|
+
if (context.domain) {
|
|
282
|
+
const existing = pattern.domainCounts.get(context.domain);
|
|
281
283
|
if (existing !== void 0) {
|
|
282
|
-
pattern.domainCounts.set(
|
|
284
|
+
pattern.domainCounts.set(context.domain, existing + 1);
|
|
283
285
|
} else if (pattern.domainCounts.size < this.MAX_DOMAINS_PER_PATTERN) {
|
|
284
|
-
pattern.domainCounts.set(
|
|
286
|
+
pattern.domainCounts.set(context.domain, 1);
|
|
285
287
|
}
|
|
286
288
|
}
|
|
287
|
-
pattern.contexts.push(
|
|
289
|
+
pattern.contexts.push(context);
|
|
288
290
|
if (pattern.contexts.length > this.MAX_CONTEXTS_PER_PATTERN) {
|
|
289
291
|
pattern.contexts.shift();
|
|
290
292
|
}
|
|
@@ -1060,6 +1062,17 @@ var init_logger = __esm({
|
|
|
1060
1062
|
timestamp,
|
|
1061
1063
|
level,
|
|
1062
1064
|
...getLogContext(),
|
|
1065
|
+
// Every process writes to the SAME log file — up to three concurrent research
|
|
1066
|
+
// runs plus the elected browser-pool leader — and until this field existed
|
|
1067
|
+
// there was nothing to tell their lines apart. Reconstructing a lock
|
|
1068
|
+
// contention from the log then meant inferring process boundaries from
|
|
1069
|
+
// timing, which is guesswork: two runs a second apart interleave into what
|
|
1070
|
+
// reads like one process contradicting itself. Twelve bytes a line buys
|
|
1071
|
+
// attributable forensics.
|
|
1072
|
+
//
|
|
1073
|
+
// After the context spread, not before: which process wrote a line is not
|
|
1074
|
+
// something a scoped context gets to claim otherwise.
|
|
1075
|
+
pid: process.pid,
|
|
1063
1076
|
message,
|
|
1064
1077
|
...firstError ? { errorMessage: redactSecrets(firstError.message), errorStack: redactSecrets(firstError.stack ?? "") } : {}
|
|
1065
1078
|
};
|
|
@@ -1098,8 +1111,8 @@ var init_logger = __esm({
|
|
|
1098
1111
|
error(...args) {
|
|
1099
1112
|
const errArg = args.find((arg) => arg instanceof Error);
|
|
1100
1113
|
if (errArg) {
|
|
1101
|
-
const
|
|
1102
|
-
errorTracker.trackError(errArg,
|
|
1114
|
+
const context = getLogContext();
|
|
1115
|
+
errorTracker.trackError(errArg, context);
|
|
1103
1116
|
}
|
|
1104
1117
|
this.emit("ERROR" /* ERROR */, ...args);
|
|
1105
1118
|
}
|
|
@@ -1166,7 +1179,7 @@ var init_metrics = __esm({
|
|
|
1166
1179
|
increment(name, value = 1, labels) {
|
|
1167
1180
|
const key = this.getKey(name, labels);
|
|
1168
1181
|
this.counters.set(key, (this.counters.get(key) ?? 0) + value);
|
|
1169
|
-
if (name
|
|
1182
|
+
if (name.startsWith("llm_")) {
|
|
1170
1183
|
logger.debug(`[Metrics] Incremented ${name} by ${value}${labels ? ` (labels: ${JSON.stringify(labels)})` : ""}`);
|
|
1171
1184
|
}
|
|
1172
1185
|
}
|
|
@@ -1270,6 +1283,28 @@ var init_metrics = __esm({
|
|
|
1270
1283
|
async measure(name, action, labels) {
|
|
1271
1284
|
return this.getActive().measure(name, action, labels);
|
|
1272
1285
|
}
|
|
1286
|
+
/**
|
|
1287
|
+
* Emit API that ALWAYS targets the session registry, ignoring any active run
|
|
1288
|
+
* context.
|
|
1289
|
+
*
|
|
1290
|
+
* For process-lifetime callbacks that happen to be *registered* during a run.
|
|
1291
|
+
* AsyncLocalStorage resolves at emit time from the async resource that fired,
|
|
1292
|
+
* and for a listener installed on a long-lived emitter that resource is the
|
|
1293
|
+
* emitter itself — created, for every lazily-initialized subsystem here, inside
|
|
1294
|
+
* whichever run first needed it. So a worker-pool error handler installed during
|
|
1295
|
+
* run 1 keeps writing into run 1's registry for the rest of the process: those
|
|
1296
|
+
* counts disappear from every later run's summary (the registry was discarded
|
|
1297
|
+
* when the run ended) and the dead registry stays reachable through the closure.
|
|
1298
|
+
*
|
|
1299
|
+
* Use this only where the emission is genuinely process-scoped. Anything that
|
|
1300
|
+
* describes work a run asked for belongs in the run registry, which is what the
|
|
1301
|
+
* plain emit methods above give you.
|
|
1302
|
+
*/
|
|
1303
|
+
session = {
|
|
1304
|
+
increment: (name, value = 1, labels) => this._session.increment(name, value, labels),
|
|
1305
|
+
setGauge: (name, value, labels) => this._session.setGauge(name, value, labels),
|
|
1306
|
+
observe: (name, value, labels) => this._session.observe(name, value, labels)
|
|
1307
|
+
};
|
|
1273
1308
|
// ── Session-level read API ─────────────────────────────────────────────
|
|
1274
1309
|
/** Snapshot of the session registry (infrastructure / cross-run metrics). */
|
|
1275
1310
|
getSessionSnapshot() {
|
|
@@ -1992,7 +2027,7 @@ var workerId2 = "";
|
|
|
1992
2027
|
var pageCreationLock = Promise.resolve();
|
|
1993
2028
|
var PAGE_CREATION_HARD_RELEASE_MS = 5 * 60 * 1e3;
|
|
1994
2029
|
var wedgedContext = null;
|
|
1995
|
-
async function createPageSafe(
|
|
2030
|
+
async function createPageSafe(context) {
|
|
1996
2031
|
let release;
|
|
1997
2032
|
const nextLock = new Promise((resolve) => {
|
|
1998
2033
|
release = resolve;
|
|
@@ -2000,11 +2035,11 @@ async function createPageSafe(context2) {
|
|
|
2000
2035
|
const currentLock = pageCreationLock;
|
|
2001
2036
|
pageCreationLock = currentLock.then(() => nextLock);
|
|
2002
2037
|
await currentLock;
|
|
2003
|
-
if (
|
|
2038
|
+
if (context === wedgedContext) {
|
|
2004
2039
|
release();
|
|
2005
2040
|
throw new Error("Browser context is wedged from a prior page-creation call that never settled; a new browser context is required.");
|
|
2006
2041
|
}
|
|
2007
|
-
const pagePromise =
|
|
2042
|
+
const pagePromise = context.newPage();
|
|
2008
2043
|
let hardReleased = false;
|
|
2009
2044
|
const releaseOnce = () => {
|
|
2010
2045
|
if (hardReleased) return;
|
|
@@ -2014,17 +2049,17 @@ async function createPageSafe(context2) {
|
|
|
2014
2049
|
};
|
|
2015
2050
|
const hardReleaseTimer = setTimeout(() => {
|
|
2016
2051
|
logToDebugFile2("WARN", "[ThreadWorker] page-creation mutex force-released \u2014 newPage() did not settle within the hard timeout");
|
|
2017
|
-
wedgedContext =
|
|
2052
|
+
wedgedContext = context;
|
|
2018
2053
|
releaseOnce();
|
|
2019
2054
|
}, PAGE_CREATION_HARD_RELEASE_MS);
|
|
2020
2055
|
safeUnref(hardReleaseTimer);
|
|
2021
2056
|
pagePromise.then(
|
|
2022
2057
|
() => {
|
|
2023
|
-
if (wedgedContext ===
|
|
2058
|
+
if (wedgedContext === context) wedgedContext = null;
|
|
2024
2059
|
releaseOnce();
|
|
2025
2060
|
},
|
|
2026
2061
|
() => {
|
|
2027
|
-
if (wedgedContext ===
|
|
2062
|
+
if (wedgedContext === context) wedgedContext = null;
|
|
2028
2063
|
releaseOnce();
|
|
2029
2064
|
}
|
|
2030
2065
|
);
|
|
@@ -2086,12 +2121,12 @@ function logToDebugFile2(level, ...args) {
|
|
|
2086
2121
|
} catch {
|
|
2087
2122
|
}
|
|
2088
2123
|
}
|
|
2089
|
-
async function setupMocking(
|
|
2124
|
+
async function setupMocking(context) {
|
|
2090
2125
|
const mockSearch = process.env["PI_RESEARCH_MOCK_SEARCH"] === "true";
|
|
2091
2126
|
const mockScrape = process.env["PI_RESEARCH_MOCK_SCRAPE"] === "true";
|
|
2092
2127
|
if (!mockSearch && !mockScrape) return;
|
|
2093
2128
|
logToDebugFile2("INFO", `[Worker-${workerId2}] Setting up browser mocking (Search: ${mockSearch}, Scrape: ${mockScrape})`);
|
|
2094
|
-
await
|
|
2129
|
+
await context.route("**", (route, request) => {
|
|
2095
2130
|
const urlStr = request.url();
|
|
2096
2131
|
let url;
|
|
2097
2132
|
try {
|
|
@@ -2170,11 +2205,21 @@ async function extractSearchResults(page) {
|
|
|
2170
2205
|
return found;
|
|
2171
2206
|
});
|
|
2172
2207
|
}
|
|
2173
|
-
|
|
2208
|
+
function bindTaskAbortToPage(page, signal) {
|
|
2209
|
+
if (!signal) return;
|
|
2210
|
+
const onAbort = () => {
|
|
2211
|
+
page.close().catch(() => {
|
|
2212
|
+
});
|
|
2213
|
+
};
|
|
2214
|
+
if (signal.aborted) onAbort();
|
|
2215
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
2216
|
+
}
|
|
2217
|
+
async function executeSearchTask(_context, query, signal) {
|
|
2174
2218
|
const page = await createPageSafe(_context);
|
|
2175
2219
|
const SEARCH_TIMEOUT = parseTimeoutMs(process.env["PI_RESEARCH_SEARCH_TIMEOUT_MS"], 45e3);
|
|
2176
2220
|
page.setDefaultTimeout(SEARCH_TIMEOUT);
|
|
2177
2221
|
page.setDefaultNavigationTimeout(SEARCH_TIMEOUT);
|
|
2222
|
+
bindTaskAbortToPage(page, signal);
|
|
2178
2223
|
try {
|
|
2179
2224
|
logToDebugFile2("DEBUG", `[Worker-${workerId2}] Starting search for: ${query}`);
|
|
2180
2225
|
if (process.env["PI_RESEARCH_MOCK_SEARCH"] === "true") {
|
|
@@ -2254,6 +2299,7 @@ async function executeScrapeTask(_context, url, signal) {
|
|
|
2254
2299
|
}
|
|
2255
2300
|
let poisonedError = null;
|
|
2256
2301
|
const pendingAddrChecks = [];
|
|
2302
|
+
let oversizedPdfError = null;
|
|
2257
2303
|
try {
|
|
2258
2304
|
logToDebugFile2("DEBUG", `[Worker-${workerId2}] Starting scrape for: ${url}`);
|
|
2259
2305
|
const { validateUrlForSSRF: validateForWorker, validateUrlForSSRFSync: validateForWorkerSync } = await Promise.resolve().then(() => (init_scraper_utils(), scraper_utils_exports));
|
|
@@ -2285,6 +2331,20 @@ async function executeScrapeTask(_context, url, signal) {
|
|
|
2285
2331
|
try {
|
|
2286
2332
|
if (resp.request().resourceType() === "document" && resp.frame() === page.mainFrame()) {
|
|
2287
2333
|
finalMainFrameAddr = resp.serverAddr().catch(() => null);
|
|
2334
|
+
const check2 = Promise.resolve(resp.headerValue("content-type")).then(async (ct) => {
|
|
2335
|
+
const looksLikePdf = ct?.includes("application/pdf") || url.toLowerCase().endsWith(".pdf");
|
|
2336
|
+
if (!looksLikePdf || oversizedPdfError || poisonedError) return;
|
|
2337
|
+
const declared = parseInt(await resp.headerValue("content-length").catch(() => null) || "", 10);
|
|
2338
|
+
if (Number.isFinite(declared) && declared > MAX_PDF_SIZE) {
|
|
2339
|
+
const sizeMB = Math.round(declared / 1024 / 1024);
|
|
2340
|
+
oversizedPdfError = new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
|
|
2341
|
+
logToDebugFile2("DEBUG", `[Worker-${workerId2}] Aborting oversized PDF (${sizeMB}MB declared) before download completes: ${url}`);
|
|
2342
|
+
page.close().catch(() => {
|
|
2343
|
+
});
|
|
2344
|
+
}
|
|
2345
|
+
}).catch(() => {
|
|
2346
|
+
});
|
|
2347
|
+
pendingAddrChecks.push(check2);
|
|
2288
2348
|
}
|
|
2289
2349
|
const check = Promise.resolve(resp.serverAddr()).then((addr) => {
|
|
2290
2350
|
const ip = addr?.ipAddress;
|
|
@@ -2314,7 +2374,7 @@ async function executeScrapeTask(_context, url, signal) {
|
|
|
2314
2374
|
}
|
|
2315
2375
|
const contentType = await response?.headerValue("content-type") || "";
|
|
2316
2376
|
const status = response?.status?.() ?? 0;
|
|
2317
|
-
if (contentType.includes("application/pdf")) {
|
|
2377
|
+
if (contentType.includes("application/pdf") || url.toLowerCase().endsWith(".pdf")) {
|
|
2318
2378
|
if (!response) throw new Error(`[Worker] No response received for PDF URL: ${url}`);
|
|
2319
2379
|
if (status >= 400) throw new Error(`HTTP ${status}`);
|
|
2320
2380
|
const declaredLength = parseInt(await response.headerValue("content-length").catch(() => null) || "", 10);
|
|
@@ -2322,6 +2382,7 @@ async function executeScrapeTask(_context, url, signal) {
|
|
|
2322
2382
|
const sizeMB = Math.round(declaredLength / 1024 / 1024);
|
|
2323
2383
|
throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
|
|
2324
2384
|
}
|
|
2385
|
+
if (oversizedPdfError) throw oversizedPdfError;
|
|
2325
2386
|
const buffer = await response.body();
|
|
2326
2387
|
if (buffer.byteLength > MAX_PDF_SIZE) {
|
|
2327
2388
|
const sizeMB = Math.round(buffer.byteLength / 1024 / 1024);
|
|
@@ -2379,6 +2440,7 @@ async function executeScrapeTask(_context, url, signal) {
|
|
|
2379
2440
|
return { contentType, html, jitter };
|
|
2380
2441
|
} catch (error) {
|
|
2381
2442
|
await page.close().catch((err) => logToDebugFile2("DEBUG", `[ThreadWorker] Swallowed page close/wait error: ${err.message || String(err)}`));
|
|
2443
|
+
if (oversizedPdfError) throw oversizedPdfError;
|
|
2382
2444
|
if (poisonedError) throw poisonedError;
|
|
2383
2445
|
throw error;
|
|
2384
2446
|
} finally {
|
|
@@ -2387,10 +2449,11 @@ async function executeScrapeTask(_context, url, signal) {
|
|
|
2387
2449
|
}
|
|
2388
2450
|
var HEALTH_PRIMARY_URL = "https://lite.duckduckgo.com/lite/";
|
|
2389
2451
|
var HEALTH_FALLBACK_URL = "https://example.com/";
|
|
2390
|
-
async function executeHealthCheckAttempt(_context, navTimeoutMs, url = HEALTH_PRIMARY_URL) {
|
|
2452
|
+
async function executeHealthCheckAttempt(_context, navTimeoutMs, url = HEALTH_PRIMARY_URL, signal) {
|
|
2391
2453
|
const page = await createPageSafe(_context);
|
|
2392
2454
|
page.setDefaultTimeout(navTimeoutMs);
|
|
2393
2455
|
page.setDefaultNavigationTimeout(navTimeoutMs);
|
|
2456
|
+
bindTaskAbortToPage(page, signal);
|
|
2394
2457
|
try {
|
|
2395
2458
|
const navStart = Date.now();
|
|
2396
2459
|
await page.goto(url, { waitUntil: "domcontentloaded" });
|
|
@@ -2406,25 +2469,25 @@ async function executeHealthCheckAttempt(_context, navTimeoutMs, url = HEALTH_PR
|
|
|
2406
2469
|
throw error;
|
|
2407
2470
|
}
|
|
2408
2471
|
}
|
|
2409
|
-
async function executeHealthCheck(_context, initMs) {
|
|
2472
|
+
async function executeHealthCheck(_context, initMs, signal) {
|
|
2410
2473
|
const configuredMs = parseTimeoutMs(process.env["PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS"], 0);
|
|
2411
2474
|
const HEALTH_TIMEOUT = configuredMs > 0 ? Math.max(1e4, configuredMs) : 1e4;
|
|
2412
2475
|
if (initMs > 3e3) {
|
|
2413
2476
|
logToDebugFile2("WARN", `[Worker-${workerId2}] Browser init was slow: ${initMs}ms \u2014 leaving less headroom for DDG navigation.`);
|
|
2414
2477
|
}
|
|
2415
2478
|
try {
|
|
2416
|
-
return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT);
|
|
2479
|
+
return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_PRIMARY_URL, signal);
|
|
2417
2480
|
} catch (firstError) {
|
|
2418
2481
|
logToDebugFile2("WARN", `[Worker-${workerId2}] Health check attempt 1 failed: ${firstError instanceof Error ? firstError.message : String(firstError)}. Retrying once...`);
|
|
2419
2482
|
try {
|
|
2420
|
-
return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT);
|
|
2483
|
+
return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_PRIMARY_URL, signal);
|
|
2421
2484
|
} catch (retryError) {
|
|
2422
2485
|
const retryMsg = retryError instanceof Error ? retryError.message : String(retryError);
|
|
2423
2486
|
const isTimeout = /tim(e|ed)\s*out|timeout/i.test(retryMsg);
|
|
2424
2487
|
if (!isTimeout) {
|
|
2425
2488
|
logToDebugFile2("WARN", `[Worker-${workerId2}] Search-provider health probe failed twice (${retryMsg}); verifying open-web reachability via fallback endpoint...`);
|
|
2426
2489
|
try {
|
|
2427
|
-
const fallback = await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_FALLBACK_URL);
|
|
2490
|
+
const fallback = await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_FALLBACK_URL, signal);
|
|
2428
2491
|
logToDebugFile2("WARN", `[Worker-${workerId2}] Fallback endpoint reachable \u2014 open web is up, search provider is degraded. Proceeding with research.`);
|
|
2429
2492
|
return fallback;
|
|
2430
2493
|
} catch (fallbackError) {
|
|
@@ -2522,13 +2585,19 @@ var ConfigSchema = Type.Object({
|
|
|
2522
2585
|
SEARCH_TIMEOUT_MS: Type.Number({ minimum: 5e3, maximum: 12e4, default: 45e3 }),
|
|
2523
2586
|
/** TUI refresh debounce in milliseconds (default: 100ms) */
|
|
2524
2587
|
TUI_REFRESH_DEBOUNCE_MS: Type.Number({ minimum: 0, maximum: 1e3, default: 100 }),
|
|
2525
|
-
/**
|
|
2526
|
-
*
|
|
2527
|
-
* SEARCH_TIMEOUT_MS + this, a scrape after SCRAPE_TIMEOUT_MS + this
|
|
2528
|
-
*
|
|
2588
|
+
/** Overhead margin (ms) added on top of each browser operation's own nav timeout to
|
|
2589
|
+
* form its scheduler task-timeout ceiling: a search task times out after
|
|
2590
|
+
* SEARCH_TIMEOUT_MS + this, a scrape after SCRAPE_TIMEOUT_MS + this — covering
|
|
2591
|
+
* per-task work beyond raw navigation (page setup, result parsing). Queue wait is
|
|
2592
|
+
* NOT part of this margin: the deadline is armed on dispatch, not on enqueue, so
|
|
2593
|
+
* time spent waiting for a free worker never counts against it (see
|
|
2594
|
+
* browser-task-scheduler.ts). Cold browser-launch/context-creation time is also
|
|
2595
|
+
* separate from this margin — COLD_START_ALLOWANCE_MS
|
|
2596
|
+
* (infrastructure/browser/config.ts) covers that unconditionally on top of it,
|
|
2597
|
+
* since it isn't something a user should need to tune.
|
|
2529
2598
|
* (default: 10000) */
|
|
2530
2599
|
BROWSER_TASK_TIMEOUT_MS: Type.Number({ minimum: 2e3, maximum: 12e4, default: 1e4 }),
|
|
2531
|
-
/** Timeout for coordinator/
|
|
2600
|
+
/** Timeout for coordinator/research-lead/repair/knowledge LLM calls in ms (default: 300000 = 5 min, range: 60s-30min).
|
|
2532
2601
|
* Not exposed in TUI — controlled via PI_RESEARCH_LLM_TIMEOUT_MS env var.
|
|
2533
2602
|
* Ceiling raised to 30min (matching RESEARCHER_TIMEOUT_MS): a slow model on a
|
|
2534
2603
|
* large-context synthesis/plan call can legitimately run >10min, and the old
|
|
@@ -2536,7 +2605,7 @@ var ConfigSchema = Type.Object({
|
|
|
2536
2605
|
* (e.g. 900000), causing those calls to time out mid-generation. */
|
|
2537
2606
|
LLM_TIMEOUT_MS: Type.Number({ minimum: 6e4, maximum: 18e5, default: 3e5 }),
|
|
2538
2607
|
/** Chain-of-thought "thinking" level for the engine's own LLM calls (coordinator,
|
|
2539
|
-
*
|
|
2608
|
+
* router, synthesizer, JSON-repair, knowledge extraction) AND the researcher
|
|
2540
2609
|
* sub-agents. Default 'off': these calls emit structured JSON / cited reports, not
|
|
2541
2610
|
* open-ended reasoning, so thinking only burns the output-token budget (often
|
|
2542
2611
|
* truncating before the JSON/report block) and adds latency for at most a marginal
|
|
@@ -2551,22 +2620,26 @@ var ConfigSchema = Type.Object({
|
|
|
2551
2620
|
Type.Literal("medium"),
|
|
2552
2621
|
Type.Literal("high")
|
|
2553
2622
|
], { default: "off" }),
|
|
2554
|
-
/** Max output tokens for the coordinator plan
|
|
2555
|
-
*
|
|
2623
|
+
/** Max output tokens for the coordinator's round-1 plan. Default 16384. The routing
|
|
2624
|
+
* decision has its OWN, much smaller ceiling (it emits a decision, never a report,
|
|
2625
|
+
* and cannot synthesize mid-round under the split protocol); the final report uses
|
|
2626
|
+
* SYNTHESIS_MAX_TOKENS below. The blanket
|
|
2556
2627
|
* 4096 cap previously throttled every call and could be exhausted by a thinking block
|
|
2557
2628
|
* before any text was emitted. Capped at the model's real ceiling at call time.
|
|
2558
2629
|
* Not exposed in TUI — controlled via PI_RESEARCH_PLANNING_MAX_TOKENS env var. */
|
|
2559
2630
|
PLANNING_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 16384 }),
|
|
2560
|
-
/** Max output tokens for the final synthesized research report (the
|
|
2561
|
-
*
|
|
2631
|
+
/** Max output tokens for the final synthesized research report (the synthesizer call,
|
|
2632
|
+
* and each pass of a reduce when the corpus does not fit in one). Default 32768 so a
|
|
2633
|
+
* full cited report is never truncated. It is also the reserve subtracted from the
|
|
2634
|
+
* model's context window when budgeting that corpus. Capped at
|
|
2562
2635
|
* the model's real maxTokens at call time.
|
|
2563
2636
|
* Not exposed in TUI — controlled via PI_RESEARCH_SYNTHESIS_MAX_TOKENS env var. */
|
|
2564
2637
|
SYNTHESIS_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 32768 }),
|
|
2565
2638
|
/** LLM Model override for researcher sub-agents and knowledge synthesis.
|
|
2566
2639
|
* Format: provider/model-id (e.g. google/gemini-2.0-flash-001) or just model-id.
|
|
2567
2640
|
* When set, this overrides ctx.model for researcher sub-agents (both deep and quick)
|
|
2568
|
-
* and the knowledge synthesis background LLM. The coordinator and
|
|
2569
|
-
* use the caller's model (ctx.model).
|
|
2641
|
+
* and the knowledge synthesis background LLM. The coordinator and the research lead
|
|
2642
|
+
* always use the caller's model (ctx.model).
|
|
2570
2643
|
*/
|
|
2571
2644
|
RESEARCH_MODEL: Type.Optional(Type.String()),
|
|
2572
2645
|
/** Explicit directory for the knowledge store database (overrides default) */
|
|
@@ -2620,10 +2693,13 @@ function resolveHeadlessMode() {
|
|
|
2620
2693
|
if (process.env["PI_RESEARCH_USE_XVFB"] === "true") return "virtual";
|
|
2621
2694
|
return true;
|
|
2622
2695
|
}
|
|
2696
|
+
var HEALTHCHECK_MIN_BUDGET_MS = 45e3 + 6e4;
|
|
2697
|
+
var BROWSER_LAUNCH_TIMEOUT_MS = 9e4;
|
|
2698
|
+
var CONTEXT_CREATION_TIMEOUT_MS = 3e4;
|
|
2699
|
+
var COLD_START_ALLOWANCE_MS = BROWSER_LAUNCH_TIMEOUT_MS + CONTEXT_CREATION_TIMEOUT_MS;
|
|
2623
2700
|
|
|
2624
2701
|
// src/infrastructure/browser/thread-worker-browser.ts
|
|
2625
2702
|
var browser = null;
|
|
2626
|
-
var context = null;
|
|
2627
2703
|
var initPromise = null;
|
|
2628
2704
|
var workerId3 = "";
|
|
2629
2705
|
var activeTaskCount = 0;
|
|
@@ -2677,11 +2753,11 @@ function logToDebugFile3(level, ...args) {
|
|
|
2677
2753
|
}
|
|
2678
2754
|
}
|
|
2679
2755
|
async function initBrowser() {
|
|
2680
|
-
if (isBrowserConnected()
|
|
2756
|
+
if (isBrowserConnected()) return;
|
|
2681
2757
|
if (initPromise) return initPromise;
|
|
2682
2758
|
initPromise = (async () => {
|
|
2683
2759
|
try {
|
|
2684
|
-
if (!isBrowserConnected()
|
|
2760
|
+
if (!isBrowserConnected()) {
|
|
2685
2761
|
logToDebugFile3("INFO", `[Worker-${workerId3}] Initializing browser instance...`);
|
|
2686
2762
|
let CamoufoxModule;
|
|
2687
2763
|
try {
|
|
@@ -2690,7 +2766,7 @@ async function initBrowser() {
|
|
|
2690
2766
|
throw new Error(`[Worker] camoufox-js not found in node_modules. Please run 'npm install'. Original error: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
|
|
2691
2767
|
}
|
|
2692
2768
|
const { Camoufox } = CamoufoxModule;
|
|
2693
|
-
const launchTimeoutMs =
|
|
2769
|
+
const launchTimeoutMs = BROWSER_LAUNCH_TIMEOUT_MS;
|
|
2694
2770
|
const launchOnce = async (headless) => {
|
|
2695
2771
|
const launchPromise = Camoufox({
|
|
2696
2772
|
headless,
|
|
@@ -2734,11 +2810,24 @@ async function initBrowser() {
|
|
|
2734
2810
|
});
|
|
2735
2811
|
launchPromise.catch((err) => logToDebugFile3("DEBUG", `[ThreadWorker] Background browser launch rejection: ${err.message}`));
|
|
2736
2812
|
let launchTimeoutId;
|
|
2813
|
+
let raceLost = false;
|
|
2814
|
+
launchPromise.then((b) => {
|
|
2815
|
+
if (!raceLost) return;
|
|
2816
|
+
logToDebugFile3("WARN", `[Worker-${workerId3}] Browser launch completed after its deadline; closing the orphaned instance.`);
|
|
2817
|
+
try {
|
|
2818
|
+
b?.close?.();
|
|
2819
|
+
} catch {
|
|
2820
|
+
}
|
|
2821
|
+
}).catch(() => {
|
|
2822
|
+
});
|
|
2737
2823
|
try {
|
|
2738
2824
|
return await Promise.race([
|
|
2739
2825
|
launchPromise,
|
|
2740
2826
|
new Promise((_, reject) => {
|
|
2741
|
-
launchTimeoutId = setTimeout(() =>
|
|
2827
|
+
launchTimeoutId = setTimeout(() => {
|
|
2828
|
+
raceLost = true;
|
|
2829
|
+
reject(new Error(`Browser launch timed out after ${launchTimeoutMs}ms`));
|
|
2830
|
+
}, launchTimeoutMs);
|
|
2742
2831
|
})
|
|
2743
2832
|
]);
|
|
2744
2833
|
} finally {
|
|
@@ -2760,17 +2849,6 @@ async function initBrowser() {
|
|
|
2760
2849
|
}
|
|
2761
2850
|
}
|
|
2762
2851
|
browser = launchedBrowser;
|
|
2763
|
-
const contextPromise = browser.newContext();
|
|
2764
|
-
contextPromise.catch((err) => logToDebugFile3("DEBUG", `[ThreadWorker] Background browser context rejection: ${err.message}`));
|
|
2765
|
-
let contextTimeoutId;
|
|
2766
|
-
context = await Promise.race([
|
|
2767
|
-
contextPromise,
|
|
2768
|
-
new Promise((_, reject) => {
|
|
2769
|
-
contextTimeoutId = setTimeout(() => reject(new Error("Browser context creation timed out after 30000ms")), 3e4);
|
|
2770
|
-
})
|
|
2771
|
-
]);
|
|
2772
|
-
if (contextTimeoutId) clearTimeout(contextTimeoutId);
|
|
2773
|
-
await setupMocking(context);
|
|
2774
2852
|
logToDebugFile3("INFO", `[Worker-${workerId3}] Browser initialized.`);
|
|
2775
2853
|
}
|
|
2776
2854
|
} catch (e) {
|
|
@@ -2778,7 +2856,6 @@ async function initBrowser() {
|
|
|
2778
2856
|
Promise.resolve(browser.close()).catch((err) => logToDebugFile3("DEBUG", `[Worker-${workerId3}] Swallowed browser close error during failed init: ${err.message}`));
|
|
2779
2857
|
}
|
|
2780
2858
|
browser = null;
|
|
2781
|
-
context = null;
|
|
2782
2859
|
const msg = e instanceof Error ? e.message : String(e);
|
|
2783
2860
|
if (msg.includes("Camoufox is not installed") || msg.includes("Version information not found")) {
|
|
2784
2861
|
throw new Error(`[Worker] Browser binaries not found. Please run 'npx camoufox-js fetch' to install them.`, { cause: e });
|
|
@@ -2797,13 +2874,39 @@ async function initBrowser() {
|
|
|
2797
2874
|
})();
|
|
2798
2875
|
return initPromise;
|
|
2799
2876
|
}
|
|
2800
|
-
function
|
|
2801
|
-
|
|
2877
|
+
async function acquireTaskContext() {
|
|
2878
|
+
const contextPromise = browser.newContext();
|
|
2879
|
+
contextPromise.catch((err) => logToDebugFile3("DEBUG", `[ThreadWorker] Background browser context rejection: ${err.message}`));
|
|
2880
|
+
let contextTimeoutId;
|
|
2881
|
+
let contextRaceLost = false;
|
|
2882
|
+
contextPromise.then((c) => {
|
|
2883
|
+
if (!contextRaceLost) return;
|
|
2884
|
+
logToDebugFile3("WARN", `[Worker-${workerId3}] Browser context creation completed after its deadline; closing the orphaned context.`);
|
|
2885
|
+
try {
|
|
2886
|
+
c?.close?.();
|
|
2887
|
+
} catch {
|
|
2888
|
+
}
|
|
2889
|
+
}).catch(() => {
|
|
2890
|
+
});
|
|
2891
|
+
let newContext;
|
|
2892
|
+
try {
|
|
2893
|
+
newContext = await Promise.race([
|
|
2894
|
+
contextPromise,
|
|
2895
|
+
new Promise((_, reject) => {
|
|
2896
|
+
contextTimeoutId = setTimeout(() => {
|
|
2897
|
+
contextRaceLost = true;
|
|
2898
|
+
reject(new Error(`Browser context creation timed out after ${CONTEXT_CREATION_TIMEOUT_MS}ms`));
|
|
2899
|
+
}, CONTEXT_CREATION_TIMEOUT_MS);
|
|
2900
|
+
})
|
|
2901
|
+
]);
|
|
2902
|
+
} finally {
|
|
2903
|
+
if (contextTimeoutId) clearTimeout(contextTimeoutId);
|
|
2904
|
+
}
|
|
2905
|
+
await setupMocking(newContext);
|
|
2906
|
+
return newContext;
|
|
2802
2907
|
}
|
|
2803
2908
|
function doResetBrowser() {
|
|
2804
|
-
if (context) Promise.resolve(context.close()).catch((err) => logToDebugFile3("DEBUG", `[Worker-${workerId3}] Swallowed context close error during reset: ${err.message}`));
|
|
2805
2909
|
if (browser) Promise.resolve(browser.close()).catch((err) => logToDebugFile3("DEBUG", `[Worker-${workerId3}] Swallowed browser close error during reset: ${err.message}`));
|
|
2806
|
-
context = null;
|
|
2807
2910
|
browser = null;
|
|
2808
2911
|
}
|
|
2809
2912
|
function resetBrowser() {
|
|
@@ -2817,7 +2920,6 @@ function resetBrowser() {
|
|
|
2817
2920
|
async function cleanupBrowser2() {
|
|
2818
2921
|
const timeoutMs = 2e3;
|
|
2819
2922
|
const cleanup = async () => {
|
|
2820
|
-
if (context) await Promise.resolve(context.close()).catch((err) => logToDebugFile3("DEBUG", `[Worker-${workerId3}] Swallowed context close error during cleanup: ${err.message}`));
|
|
2821
2923
|
if (browser) await Promise.resolve(browser.close()).catch((err) => logToDebugFile3("DEBUG", `[Worker-${workerId3}] Swallowed browser close error during cleanup: ${err.message}`));
|
|
2822
2924
|
};
|
|
2823
2925
|
try {
|
|
@@ -2827,7 +2929,6 @@ async function cleanupBrowser2() {
|
|
|
2827
2929
|
]);
|
|
2828
2930
|
} catch {
|
|
2829
2931
|
} finally {
|
|
2830
|
-
context = null;
|
|
2831
2932
|
browser = null;
|
|
2832
2933
|
}
|
|
2833
2934
|
}
|
|
@@ -2876,20 +2977,22 @@ async function runTask(data) {
|
|
|
2876
2977
|
taskTimer = setTimeout(() => abortController.abort(new Error(`Task timed out after ${taskTimeoutMs}ms`)), taskTimeoutMs);
|
|
2877
2978
|
}
|
|
2878
2979
|
taskStarted();
|
|
2980
|
+
let taskContext;
|
|
2879
2981
|
try {
|
|
2880
2982
|
await initBrowser();
|
|
2881
2983
|
const initMs = Date.now() - startTime;
|
|
2984
|
+
taskContext = await acquireTaskContext();
|
|
2882
2985
|
let result;
|
|
2883
2986
|
if (type === "search") {
|
|
2884
2987
|
if (!query) throw new Error("Search task requires a query");
|
|
2885
|
-
const searchResult = await executeSearchTask(
|
|
2988
|
+
const searchResult = await executeSearchTask(taskContext, query, abortController.signal);
|
|
2886
2989
|
result = { results: searchResult.results, duration: Date.now() - startTime, jitter: searchResult.jitter };
|
|
2887
2990
|
} else if (type === "scrape") {
|
|
2888
2991
|
if (!url) throw new Error("Scrape task requires a URL");
|
|
2889
|
-
const scrapeResult = await executeScrapeTask(
|
|
2992
|
+
const scrapeResult = await executeScrapeTask(taskContext, url, abortController.signal);
|
|
2890
2993
|
result = { ...scrapeResult, duration: Date.now() - startTime };
|
|
2891
2994
|
} else if (type === "healthcheck") {
|
|
2892
|
-
const healthResult = await executeHealthCheck(
|
|
2995
|
+
const healthResult = await executeHealthCheck(taskContext, initMs, abortController.signal);
|
|
2893
2996
|
result = { ...healthResult, duration: Date.now() - startTime };
|
|
2894
2997
|
} else {
|
|
2895
2998
|
result = { error: "Unknown task type", duration: Date.now() - startTime };
|
|
@@ -2912,6 +3015,10 @@ async function runTask(data) {
|
|
|
2912
3015
|
};
|
|
2913
3016
|
} finally {
|
|
2914
3017
|
if (taskTimer !== void 0) clearTimeout(taskTimer);
|
|
3018
|
+
if (taskContext) {
|
|
3019
|
+
Promise.resolve(taskContext.close()).catch(() => {
|
|
3020
|
+
});
|
|
3021
|
+
}
|
|
2915
3022
|
taskFinished();
|
|
2916
3023
|
}
|
|
2917
3024
|
}
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -31,8 +31,8 @@ pi CLI
|
|
|
31
31
|
search burst, then hands each researcher a set of result URLs to start from.
|
|
32
32
|
4. Researchers scrape and read those pages through the scrape tooling and return cited
|
|
33
33
|
reports. They consider only what they scraped this session.
|
|
34
|
-
5. The
|
|
35
|
-
final report.
|
|
34
|
+
5. The research lead's **router** reviews the round and either runs another round or ends
|
|
35
|
+
the loop; its **synthesizer** then writes the final report from every report collected.
|
|
36
36
|
6. The result is returned as a single cited Markdown report; the cited URLs and their
|
|
37
37
|
summaries are queued into the knowledge store for future runs.
|
|
38
38
|
|
|
@@ -49,11 +49,19 @@ agent skill can pass). The pi extension's `research` tool has a minimum depth of
|
|
|
49
49
|
an in-session agent can never request quick mode.
|
|
50
50
|
|
|
51
51
|
Depth 1–3 — deep (`DeepResearchOrchestrator`): the run proceeds in **rounds**. A round
|
|
52
|
-
is one coordinate → research →
|
|
53
|
-
coordinator in round 1, by the
|
|
54
|
-
in parallel, and the
|
|
55
|
-
Two limits apply independently: how many researchers run *within*
|
|
56
|
-
rounds the run may take.
|
|
52
|
+
is one coordinate → research → route cycle — the round's agenda is planned (by the
|
|
53
|
+
coordinator in round 1, by the research lead's **router** thereafter), a batch of
|
|
54
|
+
**researchers** runs it in parallel, and the router then decides whether to run another
|
|
55
|
+
round or end the loop. Two limits apply independently: how many researchers run *within*
|
|
56
|
+
a round, and how many rounds the run may take.
|
|
57
|
+
|
|
58
|
+
The research lead is two roles, not one call doing both jobs. The router only decides; it
|
|
59
|
+
reads each report in full on the one round it arrives and only that report's short
|
|
60
|
+
coverage digest thereafter, so its input grows with team size rather than with the square
|
|
61
|
+
of the round count. The **synthesizer** runs exactly once, at the end, reads every report
|
|
62
|
+
in full, and writes the report — under a corpus budget derived from the model's context
|
|
63
|
+
window, reducing in partial passes and merging when the corpus does not fit. The two
|
|
64
|
+
prompts are `src/prompts/system-lead-router.md` and `system-lead-synthesizer.md`.
|
|
57
65
|
|
|
58
66
|
| Depth | Label | Researchers per round (max) | Rounds (max) |
|
|
59
67
|
|-------|--------|-----------------------------|--------------|
|
|
@@ -61,7 +69,7 @@ rounds the run may take.
|
|
|
61
69
|
| 2 | deep | 3 | 3 |
|
|
62
70
|
| 3 | ultra | 5 | 3 |
|
|
63
71
|
|
|
64
|
-
These are ceilings, not targets: the coordinator and
|
|
72
|
+
These are ceilings, not targets: the coordinator and router use as many researchers
|
|
65
73
|
and rounds as the topic needs. A depth-2 run, for example, may spawn up to 3 researchers
|
|
66
74
|
in each of up to 3 rounds. Queued steering messages (Alt+Enter) can unlock a few extra
|
|
67
75
|
rounds past the cap (`MAX_EXTRA_ROUNDS_WITH_STEERING`).
|
|
@@ -70,7 +78,7 @@ The coordinator also runs the initial search burst and distributes its result UR
|
|
|
70
78
|
round 1's researchers (`distributeSearchResults`), so in deep mode the researchers
|
|
71
79
|
themselves do not call `search`.
|
|
72
80
|
|
|
73
|
-
LLM-call conventions. Coordinator,
|
|
81
|
+
LLM-call conventions. Coordinator, router, synthesizer, JSON-repair, and
|
|
74
82
|
knowledge-extraction calls go through `completeSimple` (`src/core/llm/pi-ai-completion.ts`)
|
|
75
83
|
with `buildSafeOptions` (`src/core/llm/llm-utils.ts`); researcher sub-agents go through
|
|
76
84
|
`createAgentSession`.
|
|
@@ -369,7 +377,7 @@ stable `Agent` connector API, so it follows `^8`.)
|
|
|
369
377
|
|
|
370
378
|
Transient-failure resilience — every LLM call is a potential single point of failure on a
|
|
371
379
|
streaming endpoint that can drop mid-response (undici surfaces this as `terminated`). The
|
|
372
|
-
coordinator and
|
|
380
|
+
coordinator and research-lead calls retry fast transient transport failures (socket aborts, 5xx,
|
|
373
381
|
429, provider overload) with bounded exponential backoff — mirroring the per-researcher retry
|
|
374
382
|
(`PI_RESEARCH_MAX_RETRIES`) — and, if still failing, degrade to a deterministic fallback plan
|
|
375
383
|
rather than aborting the run. An app-level LLM timeout is not retried (it already spent the
|