@lincoln504/pi-research 1.0.6 → 1.0.8
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/dist/cli.mjs +195 -36
- package/dist/prompts/research-tool-usage.md +1 -0
- package/dist/thread-worker.mjs +2 -0
- package/docs/ARCHITECTURE.md +19 -4
- package/docs/CHANGELOG.md +108 -0
- package/docs/CONFIGURATION.md +1 -0
- package/docs/SDK.md +4 -1
- package/package.json +6 -5
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +53 -4
- package/src/config.ts +4 -0
- package/src/index.ts +2 -0
- package/src/infrastructure/browser/task-execution-service.ts +15 -3
- package/src/infrastructure/browser/thread-worker.mjs +2 -0
- package/src/orchestration/research-orchestration-service.ts +6 -6
- package/src/orchestration/research-synthesis-service.ts +31 -20
- package/src/orchestration/researcher-executor.ts +51 -13
- package/src/orchestration/session-state.ts +27 -4
- package/src/prompts/research-tool-usage.md +1 -0
- package/src/sdk.ts +57 -4
- package/src/youtube/index.ts +5 -4
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: pi-research
|
|
3
3
|
description: "Multi-agent web research for any coding agent. Use for anything requiring current information from the internet — news, trends, product/service/company facts, statistics, people, places, events, APIs, library behavior, or 'what is X / how does X work' questions. Also use to search a local knowledge store of previously researched findings before doing live research. Powered by the pi-research engine (search, stealth scraping, security databases, Stack Exchange). Not for reading local files, running project commands, or analyzing this repository's own source."
|
|
4
4
|
license: MIT
|
|
5
|
-
metadata: { "author": "Lincoln Deen", "version": "1.0.
|
|
5
|
+
metadata: { "author": "Lincoln Deen", "version": "1.0.8", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
|
|
6
6
|
allowed-tools: Bash(node:*)
|
|
7
7
|
---
|
|
8
8
|
|
package/src/cli.ts
CHANGED
|
@@ -31,9 +31,12 @@ import {
|
|
|
31
31
|
runDeepResearch,
|
|
32
32
|
searchKnowledge,
|
|
33
33
|
shutdownResearchSDK,
|
|
34
|
+
getLastErrorReport,
|
|
35
|
+
getLastResearcherOutcome,
|
|
34
36
|
type KnowledgeSearchResult,
|
|
35
37
|
} from './sdk.ts';
|
|
36
38
|
import type { HeadlessObserverOptions } from './orchestration/headless-observer.ts';
|
|
39
|
+
import { RESEARCH_STOPPED_ERROR_CODE } from './orchestration/session-state.ts';
|
|
37
40
|
import { validateAndSanitizeQuery } from './utils/input-validation.ts';
|
|
38
41
|
import { validateInitialLink, MAX_INITIAL_LINKS } from './utils/url-utils.ts';
|
|
39
42
|
import { exportResearchReport, appendExportMessage } from './utils/research-export.ts';
|
|
@@ -468,6 +471,11 @@ async function cmdResearch(args: ResearchArgs): Promise<number> {
|
|
|
468
471
|
const observer: HeadlessObserverOptions = makeProgressObserver();
|
|
469
472
|
|
|
470
473
|
let exit: number = EXIT.OK;
|
|
474
|
+
// Let Ctrl-C/SIGTERM (main()'s onSignal) cancel THIS run's orchestrator instead of
|
|
475
|
+
// only tearing down shared SDK services out from under it — see
|
|
476
|
+
// activeResearchAbortController's declaration for why.
|
|
477
|
+
const abortController = new AbortController();
|
|
478
|
+
activeResearchAbortController = abortController;
|
|
471
479
|
try {
|
|
472
480
|
await initResearchSDK({
|
|
473
481
|
model: runModel,
|
|
@@ -485,7 +493,7 @@ async function cmdResearch(args: ResearchArgs): Promise<number> {
|
|
|
485
493
|
observer,
|
|
486
494
|
...(args.excludeTools ? { excludeTools: args.excludeTools } : {}),
|
|
487
495
|
...(args.initialLinks ? { initialLinks: args.initialLinks } : {}),
|
|
488
|
-
});
|
|
496
|
+
}, abortController.signal);
|
|
489
497
|
|
|
490
498
|
// Optionally persist the report to a file (opt-in via
|
|
491
499
|
// PI_RESEARCH_REPORT_EXPORT_ENABLED). The SDK is a pure library and does not
|
|
@@ -503,13 +511,29 @@ async function cmdResearch(args: ResearchArgs): Promise<number> {
|
|
|
503
511
|
}
|
|
504
512
|
|
|
505
513
|
if (args.json) {
|
|
506
|
-
|
|
514
|
+
// Surface the internal quality/health signals that were previously log-only
|
|
515
|
+
// (ISSUES-ANALYSIS-2026-07-09.md, Symptom 4): a caller reading only stdout/JSON
|
|
516
|
+
// had no way to distinguish "thin report, little material out there" from "thin
|
|
517
|
+
// report because most researchers died mid-run" without opening the log file.
|
|
518
|
+
const outcome = getLastResearcherOutcome();
|
|
519
|
+
const errorReport = getLastErrorReport();
|
|
520
|
+
toStdout(pretty({
|
|
521
|
+
ok: true,
|
|
522
|
+
depth,
|
|
523
|
+
report,
|
|
524
|
+
...(reportPath ? { reportPath } : {}),
|
|
525
|
+
...(outcome ? { researchers: { planned: outcome.planned, launched: outcome.launched, succeeded: outcome.succeeded, failed: outcome.failed } } : {}),
|
|
526
|
+
...(errorReport && errorReport.totalErrors > 0 ? {
|
|
527
|
+
trackedErrors: { total: errorReport.totalErrors, uniquePatterns: errorReport.uniquePatterns },
|
|
528
|
+
} : {}),
|
|
529
|
+
}));
|
|
507
530
|
} else {
|
|
508
531
|
toStdout(report.endsWith('\n') ? report : report + '\n');
|
|
509
532
|
}
|
|
510
533
|
} catch (err) {
|
|
511
534
|
exit = reportError(err, 'research', args.json);
|
|
512
535
|
} finally {
|
|
536
|
+
if (activeResearchAbortController === abortController) activeResearchAbortController = null;
|
|
513
537
|
await safeShutdown();
|
|
514
538
|
}
|
|
515
539
|
return exit;
|
|
@@ -791,12 +815,23 @@ function describeUninstall(r: { status: string; path: string; message?: string }
|
|
|
791
815
|
// ---------------------------------------------------------------------------
|
|
792
816
|
|
|
793
817
|
/** Distinguish setup errors from runtime errors and print a clean, located message. */
|
|
794
|
-
function reportError(err: unknown, what: string, json?: boolean): number {
|
|
818
|
+
export function reportError(err: unknown, what: string, json?: boolean): number {
|
|
795
819
|
const msg = err instanceof Error ? err.message : String(err);
|
|
796
820
|
const lower = msg.toLowerCase();
|
|
797
821
|
|
|
822
|
+
// A tagged research-stop error (session-state.ts's createResearchStopError) is an
|
|
823
|
+
// orchestration/infrastructure failure (worker-pool contention, ungrounded
|
|
824
|
+
// researchers, etc.), never a credentials problem — classify it as SOFTWARE
|
|
825
|
+
// unconditionally and skip the substring heuristic below entirely. Without this,
|
|
826
|
+
// the boilerplate advice line every research-stop message carries ("Verify the
|
|
827
|
+
// model is available and the API key / context settings are valid.") contains the
|
|
828
|
+
// literal substring "api key," so the heuristic alone would misclassify EVERY
|
|
829
|
+
// research-stop — regardless of real cause — as a config error (exit 78).
|
|
830
|
+
const isTaggedResearchStop = (err as { code?: unknown } | undefined)?.code === RESEARCH_STOPPED_ERROR_CODE;
|
|
831
|
+
|
|
798
832
|
// Auth / model / config-shaped errors → CONFIG exit + config block.
|
|
799
833
|
const isConfigError =
|
|
834
|
+
!isTaggedResearchStop && (
|
|
800
835
|
lower.includes('no llm model available') ||
|
|
801
836
|
lower.includes('not found in pi') ||
|
|
802
837
|
lower.includes('no api key') ||
|
|
@@ -804,7 +839,7 @@ function reportError(err: unknown, what: string, json?: boolean): number {
|
|
|
804
839
|
lower.includes('invalid model string') ||
|
|
805
840
|
lower.includes('authentication') ||
|
|
806
841
|
lower.includes('unauthorized') ||
|
|
807
|
-
lower.includes('api key');
|
|
842
|
+
lower.includes('api key'));
|
|
808
843
|
|
|
809
844
|
// Rate limits win over config-shaped matches: a provider 429 often mentions
|
|
810
845
|
// "api key" in its body (e.g. "rate limit exceeded for this api key"), and
|
|
@@ -1175,6 +1210,15 @@ const flushAndExit = (code: number): void => {
|
|
|
1175
1210
|
});
|
|
1176
1211
|
};
|
|
1177
1212
|
|
|
1213
|
+
// Signal to the in-flight `research` run's AbortController, if any is active.
|
|
1214
|
+
// Without this, Ctrl-C only tore down shared SDK services (browser pool, LLM
|
|
1215
|
+
// clients, LanceDB) via safeShutdown() while runDeepResearch() was still using
|
|
1216
|
+
// them — the orchestrator never saw a cancellation and had no chance to return
|
|
1217
|
+
// its already-wired partial/fallback synthesis for a clean stop. Set at the
|
|
1218
|
+
// start of cmdResearch, cleared in its finally so a stale reference never
|
|
1219
|
+
// outlives that one run.
|
|
1220
|
+
let activeResearchAbortController: AbortController | null = null;
|
|
1221
|
+
|
|
1178
1222
|
async function main(argv: string[]): Promise<number> {
|
|
1179
1223
|
// Register signal handlers so SIGINT/SIGTERM always trigger SDK shutdown
|
|
1180
1224
|
// before the process exits. safeShutdown() is idempotent — safe to call
|
|
@@ -1184,6 +1228,11 @@ async function main(argv: string[]): Promise<number> {
|
|
|
1184
1228
|
if (_signalCleanupDone) return;
|
|
1185
1229
|
_signalCleanupDone = true;
|
|
1186
1230
|
toStderr(`\n[pi-research] ${sig} — cleaning up…\n`);
|
|
1231
|
+
// Abort the in-flight research run FIRST (synchronously ahead of the shutdown
|
|
1232
|
+
// teardown below) so the orchestrator's existing signal-aware cancellation path
|
|
1233
|
+
// gets a chance to return a partial/fallback synthesis instead of racing a live
|
|
1234
|
+
// browser-pool/LLM teardown out from under it.
|
|
1235
|
+
activeResearchAbortController?.abort();
|
|
1187
1236
|
// Fire-and-forget: we can't await here, but safeShutdown swallows errors
|
|
1188
1237
|
// and the finally blocks in each command handler also call it. Drain via
|
|
1189
1238
|
// flushAndExit (not a bare process.exit) so buffered stdout isn't truncated.
|
package/src/config.ts
CHANGED
|
@@ -28,6 +28,8 @@ export const ConfigSchema = Type.Object({
|
|
|
28
28
|
RESEARCHER_MAX_RETRIES: Type.Number({ minimum: 0, maximum: 5, default: 2 }),
|
|
29
29
|
/** Base delay between retries in milliseconds (default: 2000, range: 100-10000) */
|
|
30
30
|
RESEARCHER_MAX_RETRY_DELAY_MS: Type.Number({ minimum: 100, maximum: 10000, default: 2000 }),
|
|
31
|
+
/** Unique researcher failures that abort the entire run (default: 2, range: 1-10) */
|
|
32
|
+
MAX_FAILED_RESEARCHERS: Type.Number({ minimum: 1, maximum: 10, default: 2 }),
|
|
31
33
|
/** Target depth for recursive research (default: 1, range: 1-3) */
|
|
32
34
|
DEFAULT_RESEARCH_DEPTH: Type.Number({ minimum: 1, maximum: 3, default: 1 }),
|
|
33
35
|
/** Number of batches to allow for a single scrape tool call (default: 2, 0=unlimited) */
|
|
@@ -690,6 +692,7 @@ export function saveConfig(config: Config, scope: 'local' | 'user' = 'local', cw
|
|
|
690
692
|
PI_RESEARCH_MAX_RESEARCHERS: String(config.MAX_CONCURRENT_RESEARCHERS),
|
|
691
693
|
PI_RESEARCH_MAX_RETRIES: String(config.RESEARCHER_MAX_RETRIES),
|
|
692
694
|
PI_RESEARCH_RETRY_DELAY_MS: String(config.RESEARCHER_MAX_RETRY_DELAY_MS),
|
|
695
|
+
PI_RESEARCH_MAX_FAILED_RESEARCHERS: String(config.MAX_FAILED_RESEARCHERS ?? DEFAULTS.MAX_FAILED_RESEARCHERS),
|
|
693
696
|
PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS: String(config.HEALTH_CHECK_TIMEOUT_MS ?? DEFAULTS.HEALTH_CHECK_TIMEOUT_MS),
|
|
694
697
|
PI_RESEARCH_SEARCH_TIMEOUT_MS: String(config.SEARCH_TIMEOUT_MS),
|
|
695
698
|
PI_RESEARCH_TUI_REFRESH_DEBOUNCE_MS: String(config.TUI_REFRESH_DEBOUNCE_MS),
|
|
@@ -912,6 +915,7 @@ export function createConfig(env: Record<string, string | undefined>, processEnv
|
|
|
912
915
|
MAX_CONCURRENT_RESEARCHERS: parseEnvNumber(e, 'PI_RESEARCH_MAX_RESEARCHERS', DEFAULTS.MAX_CONCURRENT_RESEARCHERS, 1, 5, true),
|
|
913
916
|
RESEARCHER_MAX_RETRIES: parseEnvNumber(e, 'PI_RESEARCH_MAX_RETRIES', DEFAULTS.RESEARCHER_MAX_RETRIES, 0, 5, true),
|
|
914
917
|
RESEARCHER_MAX_RETRY_DELAY_MS: parseEnvNumber(e, 'PI_RESEARCH_RETRY_DELAY_MS', DEFAULTS.RESEARCHER_MAX_RETRY_DELAY_MS, 100, 10000),
|
|
918
|
+
MAX_FAILED_RESEARCHERS: parseEnvNumber(e, 'PI_RESEARCH_MAX_FAILED_RESEARCHERS', DEFAULTS.MAX_FAILED_RESEARCHERS, 1, 10, true),
|
|
915
919
|
DEFAULT_RESEARCH_DEPTH: parseEnvNumber(e, 'PI_RESEARCH_DEFAULT_RESEARCH_DEPTH', DEFAULTS.DEFAULT_RESEARCH_DEPTH, 1, 3, true),
|
|
916
920
|
MAX_SCRAPE_BATCHES: parseEnvNumber(e, 'PI_RESEARCH_MAX_SCRAPE_BATCHES', DEFAULTS.MAX_SCRAPE_BATCHES, 0, 99, true),
|
|
917
921
|
MAX_GATHERING_CALLS: parseEnvNumber(e, 'PI_RESEARCH_MAX_GATHERING_CALLS', DEFAULTS.MAX_GATHERING_CALLS, 1, 100, true),
|
package/src/index.ts
CHANGED
|
@@ -99,11 +99,17 @@ export async function runBrowserTask<T>(
|
|
|
99
99
|
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
100
100
|
} else {
|
|
101
101
|
// True socket/connection error — restart the scheduler with thundering herd guard.
|
|
102
|
+
// Do NOT force-clear remote leader state here: a transient socket
|
|
103
|
+
// hiccup against a live-but-busy leader is not proof the leader is
|
|
104
|
+
// dead. forceSchedulerRestart(false) still clears state when its own
|
|
105
|
+
// PID+start-time liveness probe confirms the registered leader is
|
|
106
|
+
// actually gone; it only skips clearing when that probe finds the
|
|
107
|
+
// leader alive, which is exactly the case we must not disturb.
|
|
102
108
|
const now = Date.now();
|
|
103
109
|
if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
|
|
104
110
|
lastRestartTime = now;
|
|
105
111
|
logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
|
|
106
|
-
await forceSchedulerRestart(
|
|
112
|
+
await forceSchedulerRestart(false, container);
|
|
107
113
|
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
108
114
|
} else {
|
|
109
115
|
logger.warn(`[BrowserManager] Scheduler restart recently triggered, waiting for pool idle...`);
|
|
@@ -141,11 +147,14 @@ export async function runBrowserHealthCheck(config?: Config, retries = 1, signal
|
|
|
141
147
|
if (isPoolShutdownError(error)) {
|
|
142
148
|
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
143
149
|
} else {
|
|
150
|
+
// See runBrowserTask: don't force-clear remote leader state on a bare
|
|
151
|
+
// transient socket error — let the liveness probe inside
|
|
152
|
+
// forceSchedulerRestart decide.
|
|
144
153
|
const now = Date.now();
|
|
145
154
|
if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
|
|
146
155
|
lastRestartTime = now;
|
|
147
156
|
logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
|
|
148
|
-
await forceSchedulerRestart(
|
|
157
|
+
await forceSchedulerRestart(false, container);
|
|
149
158
|
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
150
159
|
} else {
|
|
151
160
|
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
@@ -189,11 +198,14 @@ export async function runWorkerSearch(query: string, config?: Config, signal?: A
|
|
|
189
198
|
if (isPoolShutdownError(error)) {
|
|
190
199
|
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
191
200
|
} else {
|
|
201
|
+
// See runBrowserTask: don't force-clear remote leader state on a bare
|
|
202
|
+
// transient socket error — let the liveness probe inside
|
|
203
|
+
// forceSchedulerRestart decide.
|
|
192
204
|
const now = Date.now();
|
|
193
205
|
if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
|
|
194
206
|
lastRestartTime = now;
|
|
195
207
|
logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
|
|
196
|
-
await forceSchedulerRestart(
|
|
208
|
+
await forceSchedulerRestart(false, container);
|
|
197
209
|
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
198
210
|
} else {
|
|
199
211
|
await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
|
|
@@ -2219,6 +2219,8 @@ var ConfigSchema = Type.Object({
|
|
|
2219
2219
|
RESEARCHER_MAX_RETRIES: Type.Number({ minimum: 0, maximum: 5, default: 2 }),
|
|
2220
2220
|
/** Base delay between retries in milliseconds (default: 2000, range: 100-10000) */
|
|
2221
2221
|
RESEARCHER_MAX_RETRY_DELAY_MS: Type.Number({ minimum: 100, maximum: 1e4, default: 2e3 }),
|
|
2222
|
+
/** Unique researcher failures that abort the entire run (default: 2, range: 1-10) */
|
|
2223
|
+
MAX_FAILED_RESEARCHERS: Type.Number({ minimum: 1, maximum: 10, default: 2 }),
|
|
2222
2224
|
/** Target depth for recursive research (default: 1, range: 1-3) */
|
|
2223
2225
|
DEFAULT_RESEARCH_DEPTH: Type.Number({ minimum: 1, maximum: 3, default: 1 }),
|
|
2224
2226
|
/** Number of batches to allow for a single scrape tool call (default: 2, 0=unlimited) */
|
|
@@ -37,7 +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 { recordResearcherFailure, shouldStopResearch,
|
|
40
|
+
import { recordResearcherFailure, shouldStopResearch, createResearchStopError } from './session-state.ts';
|
|
41
41
|
import type { ResearchSessionService } from './research-session-service.ts';
|
|
42
42
|
import { QuickResearchOrchestrator } from './quick-research-orchestrator.ts';
|
|
43
43
|
import { DeepResearchOrchestrator } from './deep-research-orchestrator.ts';
|
|
@@ -291,6 +291,7 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
|
|
|
291
291
|
const active = new Set<Promise<void>>();
|
|
292
292
|
// Honour MAX_CONCURRENT_RESEARCHERS — prevents resource spikes when a plan has many researchers.
|
|
293
293
|
const maxConcurrent: number = (orchestratorOptions.config as Config)?.MAX_CONCURRENT_RESEARCHERS ?? DEFAULTS.MAX_CONCURRENT_RESEARCHERS;
|
|
294
|
+
const maxFailedResearchers: number = (orchestratorOptions.config as Config)?.MAX_FAILED_RESEARCHERS ?? DEFAULTS.MAX_FAILED_RESEARCHERS;
|
|
294
295
|
|
|
295
296
|
for (const configItem of researchers) {
|
|
296
297
|
if (signal?.aborted) break;
|
|
@@ -299,10 +300,10 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
|
|
|
299
300
|
// post-launch check below only fires after a researcher is in-flight, so
|
|
300
301
|
// without this an early check, up to maxConcurrent-1 extra researchers could
|
|
301
302
|
// start past the stop threshold and waste a browser slot + an LLM call.
|
|
302
|
-
if (shouldStopResearch(sessionId, researchId)) {
|
|
303
|
+
if (shouldStopResearch(sessionId, researchId, maxFailedResearchers)) {
|
|
303
304
|
const sessionService = await getService<ResearchSessionService>(ServiceNames.RESEARCH_SESSION_SERVICE, ctx, container);
|
|
304
305
|
await sessionService.abortAllSessions(researchId);
|
|
305
|
-
throw
|
|
306
|
+
throw createResearchStopError(sessionId, researchId);
|
|
306
307
|
}
|
|
307
308
|
|
|
308
309
|
// Enforce the concurrency cap before launching the next researcher.
|
|
@@ -391,13 +392,12 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
|
|
|
391
392
|
if (signal?.aborted) break;
|
|
392
393
|
}
|
|
393
394
|
|
|
394
|
-
if (shouldStopResearch(sessionId, researchId)) {
|
|
395
|
+
if (shouldStopResearch(sessionId, researchId, maxFailedResearchers)) {
|
|
395
396
|
const sessionService = await getService<ResearchSessionService>(ServiceNames.RESEARCH_SESSION_SERVICE, ctx, container);
|
|
396
397
|
// Abort sessions specifically for this researchId, not the whole piSessionId
|
|
397
398
|
await sessionService.abortAllSessions(researchId);
|
|
398
399
|
|
|
399
|
-
|
|
400
|
-
throw new Error(stopMessage);
|
|
400
|
+
throw createResearchStopError(sessionId, researchId);
|
|
401
401
|
}
|
|
402
402
|
}
|
|
403
403
|
|
|
@@ -178,28 +178,39 @@ export class ResearchSynthesisService implements IService {
|
|
|
178
178
|
let globalCitations: GlobalCitation[] =
|
|
179
179
|
reports.size > 0 ? normalizeCitations(reports).globalCitations : [];
|
|
180
180
|
|
|
181
|
-
// Robustness fallback:
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
181
|
+
// Robustness fallback: reconcile against ground-truth provenance — the URLs
|
|
182
|
+
// ACTUALLY, successfully scraped this session (registerScrapedLinks only records
|
|
183
|
+
// HTTP-success fetches). This runs even when SOME citations parsed: a
|
|
184
|
+
// multi-researcher run where only one researcher's report has an unparseable/
|
|
185
|
+
// missing "CITED LINKS" block still needs that researcher's real sources folded
|
|
186
|
+
// in — gating this on globalCitations being entirely EMPTY only ever caught the
|
|
187
|
+
// all-researchers-failed-to-cite case and silently dropped a partial researcher's
|
|
188
|
+
// sources whenever ANY other researcher cited normally. Only URLs not already
|
|
189
|
+
// represented (by normalized URL) are added, so parsed citations — richer, with
|
|
190
|
+
// descriptions and source tags — are never overwritten.
|
|
191
|
+
if (reports.size > 0) {
|
|
189
192
|
const scraped = getScrapedLinks(sessionId);
|
|
190
193
|
if (scraped.length > 0) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
194
|
+
const known = new Set(globalCitations.map((c) => normalizeUrl(c.url)));
|
|
195
|
+
const missing = scraped.filter((url) => !known.has(normalizeUrl(url)));
|
|
196
|
+
if (missing.length > 0) {
|
|
197
|
+
const startId = globalCitations.reduce((max, c) => Math.max(max, c.id), 0) + 1;
|
|
198
|
+
// Label per actual provenance: the pool also contains transcript watch
|
|
199
|
+
// URLs the youtube_transcript tool registered — calling those "Scrape"
|
|
200
|
+
// misattributes the report's real source material.
|
|
201
|
+
globalCitations = [
|
|
202
|
+
...globalCitations,
|
|
203
|
+
...missing.map((url, i) => ({
|
|
204
|
+
id: startId + i,
|
|
205
|
+
url,
|
|
206
|
+
description: '',
|
|
207
|
+
source: isTranscribedLink(sessionId, url) ? 'YouTube Transcript' : 'Scrape',
|
|
208
|
+
})),
|
|
209
|
+
];
|
|
210
|
+
logger.warn(
|
|
211
|
+
`[ResearchSynthesisService] Folded in ${missing.length} source(s) from scrape/transcript provenance not covered by parsed citations.`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
203
214
|
}
|
|
204
215
|
}
|
|
205
216
|
|
|
@@ -21,6 +21,7 @@ import { loadPrompt } from '../core/llm/prompts.ts';
|
|
|
21
21
|
import { injectCurrentDate } from '../core/llm/inject-date.ts';
|
|
22
22
|
import { recordResearcherFailure, getSteeringMessages } from './session-state.ts';
|
|
23
23
|
import type { RunResearcherOptions } from './orchestration-types.ts';
|
|
24
|
+
import { search } from '../web-research/search.ts';
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* Run a single researcher with retries
|
|
@@ -62,22 +63,59 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
|
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
const researcherPromptTemplate = loadPrompt('researcher');
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
66
|
+
|
|
67
|
+
let effectiveInitialLinks = initialLinks;
|
|
68
|
+
if (effectiveInitialLinks.length === 0 && historicalUrls.length === 0) {
|
|
69
|
+
// The plan-wide initial search burst (run once, before any researcher starts)
|
|
70
|
+
// can come back empty for THIS researcher's queries specifically because the
|
|
71
|
+
// shared browser pool was mid-recovery (e.g. leader-election churn, see
|
|
72
|
+
// task-execution-service.ts) at that exact moment — not because the topic has
|
|
73
|
+
// no results. Unlike every other researcher failure mode, this one used to
|
|
74
|
+
// `return` immediately, bypassing RESEARCHER_MAX_RETRIES entirely: a single
|
|
75
|
+
// transient pool hiccup counted as a full, unretried researcher failure and,
|
|
76
|
+
// combined with MAX_FAILED_RESEARCHERS, could abort a run with several other
|
|
77
|
+
// researchers still healthy and in flight. Give it the same bounded retry
|
|
78
|
+
// budget as the rest of this function, re-issuing a fresh search for this
|
|
79
|
+
// researcher's own queries before giving up.
|
|
80
|
+
const retryQueries = researcherConfig.queries?.length ? researcherConfig.queries : [researcherConfig.goal];
|
|
81
|
+
const maxLinkAttempts = config.RESEARCHER_MAX_RETRIES + 1;
|
|
82
|
+
for (let linkAttempt = 1; linkAttempt <= maxLinkAttempts && effectiveInitialLinks.length === 0; linkAttempt++) {
|
|
83
|
+
if (linkAttempt > 1) {
|
|
84
|
+
if (signal?.aborted || container?.isDisposing) break;
|
|
85
|
+
const delay = Math.min(1000 * Math.pow(2, linkAttempt - 2), config.RESEARCHER_MAX_RETRY_DELAY_MS);
|
|
86
|
+
logger.warn(`[ResearcherExecutor] Researcher ${id} has no initial search results; retry ${linkAttempt - 1}/${config.RESEARCHER_MAX_RETRIES} after ${delay}ms`);
|
|
87
|
+
await new Promise(r => setTimeout(r, delay));
|
|
88
|
+
}
|
|
89
|
+
if (signal?.aborted || container?.isDisposing) break;
|
|
90
|
+
try {
|
|
91
|
+
const queryResults = await search(retryQueries, config, signal, undefined, container);
|
|
92
|
+
const urls = new Set<string>();
|
|
93
|
+
for (const qr of queryResults) {
|
|
94
|
+
for (const r of qr.results || []) urls.add(r.url);
|
|
95
|
+
}
|
|
96
|
+
effectiveInitialLinks = [...urls];
|
|
97
|
+
} catch (err) {
|
|
98
|
+
logger.debug(`[ResearcherExecutor] Researcher ${id} link-retry search burst failed:`, err);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (effectiveInitialLinks.length === 0) {
|
|
103
|
+
logger.warn(`[ResearcherExecutor] Researcher ${id} has no initial search results or historical links after ${maxLinkAttempts} attempt(s); skipping.`);
|
|
104
|
+
// Use the resolved sessionId (= piSessionId), NOT the raw ctx.sessionId: when
|
|
105
|
+
// ctx.sessionId is unset but sessionManager supplies a real id (common in SDK
|
|
106
|
+
// use), recording under ctx.sessionId would file this no-links skip under a
|
|
107
|
+
// different id than shouldStopResearch() checks, so the fast-stop guard would
|
|
108
|
+
// under-count exactly the broad-search-failure case it exists to catch.
|
|
109
|
+
recordResearcherFailure(sessionId, researchId, id, 'no initial search results or historical links (search produced nothing usable)');
|
|
110
|
+
metrics.increment('researcher_skipped_total', 1, { mode: 'deep', complexity: String(complexity), reason: 'no_initial_links' });
|
|
111
|
+
observer?.onResearcherFailure?.(id, 'No initial search results or historical links available');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
76
114
|
}
|
|
77
115
|
|
|
78
116
|
let evidenceSection = '';
|
|
79
|
-
if (
|
|
80
|
-
evidenceSection = `## Evidence Provided\nInitial search results provided the following URLs to investigate:\n${
|
|
117
|
+
if (effectiveInitialLinks.length > 0) {
|
|
118
|
+
evidenceSection = `## Evidence Provided\nInitial search results provided the following URLs to investigate:\n${effectiveInitialLinks.map(l => `- ${l}`).join('\n')}`;
|
|
81
119
|
}
|
|
82
120
|
|
|
83
121
|
const maxAttempts = config.RESEARCHER_MAX_RETRIES + 1;
|
|
@@ -605,16 +605,18 @@ export function getFailedResearchers(piSessionId: string | undefined, researchId
|
|
|
605
605
|
}
|
|
606
606
|
|
|
607
607
|
/**
|
|
608
|
-
*
|
|
608
|
+
* Default (and fallback, for callers not passing a resolved Config) max allowed
|
|
609
|
+
* unique failed researchers before the run aborts. Configurable per-run via
|
|
610
|
+
* Config.MAX_FAILED_RESEARCHERS / PI_RESEARCH_MAX_FAILED_RESEARCHERS.
|
|
609
611
|
*/
|
|
610
|
-
const
|
|
612
|
+
const DEFAULT_MAX_FAILED_RESEARCHERS = 2;
|
|
611
613
|
|
|
612
614
|
/**
|
|
613
615
|
* Check if research should stop due to too many unique failures
|
|
614
616
|
*/
|
|
615
|
-
export function shouldStopResearch(piSessionId: string | undefined, researchId: string): boolean {
|
|
617
|
+
export function shouldStopResearch(piSessionId: string | undefined, researchId: string, maxFailedResearchers: number = DEFAULT_MAX_FAILED_RESEARCHERS): boolean {
|
|
616
618
|
const sid = normalizeSessionId(piSessionId);
|
|
617
|
-
return getFailedResearchers(sid, researchId).length >=
|
|
619
|
+
return getFailedResearchers(sid, researchId).length >= maxFailedResearchers;
|
|
618
620
|
}
|
|
619
621
|
|
|
620
622
|
/**
|
|
@@ -659,6 +661,27 @@ export function getResearchStopMessage(piSessionId: string | undefined, research
|
|
|
659
661
|
].join('\n');
|
|
660
662
|
}
|
|
661
663
|
|
|
664
|
+
/**
|
|
665
|
+
* Stable error code for a fail-fast research-stop error. cli.ts's reportError()
|
|
666
|
+
* checks this BEFORE its 'api key'-substring config-error heuristic: the
|
|
667
|
+
* boilerplate advice line above ("...API key / context settings are valid.")
|
|
668
|
+
* is appended to every research-stop message regardless of cause, so a
|
|
669
|
+
* substring match alone misclassifies every worker-pool/infra-driven stop as
|
|
670
|
+
* a config error (exit 78) instead of a software error (exit 70).
|
|
671
|
+
*/
|
|
672
|
+
export const RESEARCH_STOPPED_ERROR_CODE = 'RESEARCH_STOPPED';
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Build the Error to throw when shouldStopResearch() trips, tagged with
|
|
676
|
+
* RESEARCH_STOPPED_ERROR_CODE so callers can classify it without re-parsing
|
|
677
|
+
* the (human-oriented, boilerplate-laden) message text.
|
|
678
|
+
*/
|
|
679
|
+
export function createResearchStopError(piSessionId: string | undefined, researchId: string): Error & { code: string } {
|
|
680
|
+
const err = new Error(getResearchStopMessage(piSessionId, researchId)) as Error & { code: string };
|
|
681
|
+
err.code = RESEARCH_STOPPED_ERROR_CODE;
|
|
682
|
+
return err;
|
|
683
|
+
}
|
|
684
|
+
|
|
662
685
|
/**
|
|
663
686
|
* Get all active research panels in a Pi session, in display order (newest first)
|
|
664
687
|
*/
|
|
@@ -85,6 +85,7 @@ The coordinator will plan as many researchers as needed (up to the max). You do
|
|
|
85
85
|
|
|
86
86
|
**Multiple Topics:**
|
|
87
87
|
- If you need to research multiple unrelated topics of **truly distinct scope**, you may emit multiple `research` calls **simultaneously in a single turn**. Each call gets its own isolated research run with its own coordinator and researchers.
|
|
88
|
+
- **Maximum 4 simultaneous calls** — Do NOT make more than 4 `research` calls in a single turn, even if you have more unrelated topics. Exceeding 4 simultaneous research runs can overload the system and degrade performance. If you have more than 4 truly distinct topics, they can likely be consolidated into fewer calls (group related subtopics, use broader queries, or batch across multiple turns).
|
|
88
89
|
- Example: `research("bananas")` and `research("quantum computing")` in the same turn — these have zero overlap.
|
|
89
90
|
- **Group related topics**: If topics share ANY scope or could inform each other, combine them into a single `research` call. The internal coordinator will decompose them into parallel researcher agents. Do NOT split related sub-topics into separate calls.
|
|
90
91
|
- Example: `research("Python async performance and Rust async performance comparison")` — NOT separate calls for Python and Rust, since the comparison IS the research goal.
|
package/src/sdk.ts
CHANGED
|
@@ -22,11 +22,12 @@ import { getService, resetServiceContainer, getServiceContainer } from './core/s
|
|
|
22
22
|
import type { ServiceContainer } from './core/service-registry.ts';
|
|
23
23
|
import { ServiceNames } from './core/service-interfaces.ts';
|
|
24
24
|
import type { IKnowledgeStoreService } from './core/interfaces/knowledge-interfaces.ts';
|
|
25
|
-
import type {
|
|
26
|
-
IResearchOrchestration,
|
|
25
|
+
import type {
|
|
26
|
+
IResearchOrchestration,
|
|
27
27
|
IResearchSynthesisService,
|
|
28
|
-
ResearchOptions
|
|
28
|
+
ResearchOptions
|
|
29
29
|
} from './core/interfaces/orchestration-interfaces.ts';
|
|
30
|
+
import type { IPlanningService } from './core/interfaces/planning-interfaces.ts';
|
|
30
31
|
import type { Model } from '@earendil-works/pi-ai';
|
|
31
32
|
import { type ExtensionContext, ModelRegistry } from '@earendil-works/pi-coding-agent';
|
|
32
33
|
import { logger, createLogger, setLogger } from './logger.ts';
|
|
@@ -78,6 +79,22 @@ let _lastRunSummary: RunSummary | null = null;
|
|
|
78
79
|
// run end when errors occurred, and consumers can read the full report after the run.
|
|
79
80
|
let _lastErrorReport: ErrorReport | null = null;
|
|
80
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Researcher-outcome summary for the most recent runDeepResearch call: how many
|
|
84
|
+
* researchers were planned/launched vs. how many ultimately failed (never landed
|
|
85
|
+
* a report) vs. succeeded, plus the first recorded failure reason per failed
|
|
86
|
+
* researcher. Lets a caller distinguish "thin report, little material out there"
|
|
87
|
+
* from "thin report because most researchers died" without opening the log file.
|
|
88
|
+
*/
|
|
89
|
+
export interface ResearcherOutcome {
|
|
90
|
+
planned: number;
|
|
91
|
+
launched: number;
|
|
92
|
+
succeeded: number;
|
|
93
|
+
failed: number;
|
|
94
|
+
failureReasons: Record<string, string>;
|
|
95
|
+
}
|
|
96
|
+
let _lastResearcherOutcome: ResearcherOutcome | null = null;
|
|
97
|
+
|
|
81
98
|
// Signal handler state — registered once, removed on clean shutdown.
|
|
82
99
|
let _onSignal: ((signal: string) => void) | null = null;
|
|
83
100
|
let _sigintHandler: (() => void) | null = null;
|
|
@@ -512,6 +529,7 @@ export async function runDeepResearch(
|
|
|
512
529
|
metrics.recordRunSummary(_lastRunSummary);
|
|
513
530
|
_lastErrorReport = runTracker.getReport();
|
|
514
531
|
logRunErrorSummary(_lastErrorReport, depthLabel, 'success');
|
|
532
|
+
_lastResearcherOutcome = await captureResearcherOutcome(sessionId, researchId, _lastRunSummary.snapshot);
|
|
515
533
|
return result;
|
|
516
534
|
} catch (err) {
|
|
517
535
|
const completedAt = Date.now();
|
|
@@ -524,6 +542,7 @@ export async function runDeepResearch(
|
|
|
524
542
|
metrics.recordRunSummary(_lastRunSummary);
|
|
525
543
|
_lastErrorReport = runTracker.getReport();
|
|
526
544
|
logRunErrorSummary(_lastErrorReport, depthLabel, 'error');
|
|
545
|
+
_lastResearcherOutcome = await captureResearcherOutcome(sessionId, researchId, _lastRunSummary.snapshot);
|
|
527
546
|
throw err;
|
|
528
547
|
} finally {
|
|
529
548
|
// Free the per-run PiSessionState that shouldStopResearch()/getFailedResearchers()
|
|
@@ -564,6 +583,14 @@ export function getLastErrorReport(): ErrorReport | null {
|
|
|
564
583
|
return _lastErrorReport;
|
|
565
584
|
}
|
|
566
585
|
|
|
586
|
+
/**
|
|
587
|
+
* Researcher-outcome summary (planned/launched/succeeded/failed + failure reasons)
|
|
588
|
+
* for the most recent run. Null until the first run completes; cleared on shutdown.
|
|
589
|
+
*/
|
|
590
|
+
export function getLastResearcherOutcome(): ResearcherOutcome | null {
|
|
591
|
+
return _lastResearcherOutcome;
|
|
592
|
+
}
|
|
593
|
+
|
|
567
594
|
/**
|
|
568
595
|
* Emit a single compact line summarizing a run's tracked errors, so they are visible in
|
|
569
596
|
* the operator's log instead of silently accumulating. No-op when the run was clean.
|
|
@@ -576,6 +603,31 @@ function logRunErrorSummary(report: ErrorReport | null, depthLabel: string, stat
|
|
|
576
603
|
logger.warn(`[SDK] run ${status} with ${report.totalErrors} tracked error(s) across ${report.uniquePatterns} pattern(s) [${depthLabel}]${top ? ` — top: ${top}` : ''}`);
|
|
577
604
|
}
|
|
578
605
|
|
|
606
|
+
/**
|
|
607
|
+
* Capture how many researchers were planned/launched/failed for this run, BEFORE
|
|
608
|
+
* the caller's `finally` block frees the PiSessionState (endResearchSession) that
|
|
609
|
+
* getFailedResearchers()/getResearcherFailureReasons() read from. Best-effort:
|
|
610
|
+
* this is instrumentation only and must never affect the run's own result.
|
|
611
|
+
*/
|
|
612
|
+
async function captureResearcherOutcome(sessionId: string, researchId: string, snapshot: IMetricsSnapshot): Promise<ResearcherOutcome | null> {
|
|
613
|
+
try {
|
|
614
|
+
const planningService = await getService<IPlanningService>(ServiceNames.PLANNING, undefined, globalContainer!);
|
|
615
|
+
const planned = planningService.getTotalResearchersPlanned(researchId);
|
|
616
|
+
const launched = extractRunStats(snapshot)?.researchersLaunched ?? 0;
|
|
617
|
+
const failed = getFailedResearchers(sessionId, researchId);
|
|
618
|
+
return {
|
|
619
|
+
planned,
|
|
620
|
+
launched,
|
|
621
|
+
succeeded: Math.max(0, launched - failed.length),
|
|
622
|
+
failed: failed.length,
|
|
623
|
+
failureReasons: getResearcherFailureReasons(sessionId, researchId),
|
|
624
|
+
};
|
|
625
|
+
} catch (err) {
|
|
626
|
+
logger.debug('[SDK] Failed to capture researcher outcome (non-fatal):', err);
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
579
631
|
/**
|
|
580
632
|
* Distilled, human-relevant stats from the most recent run (researchers launched,
|
|
581
633
|
* searches, URLs analyzed/failed, tokens, cost, per-tool usage). Null if no run
|
|
@@ -744,7 +796,7 @@ export async function searchKnowledge(
|
|
|
744
796
|
};
|
|
745
797
|
}
|
|
746
798
|
|
|
747
|
-
import { clearAllSessionState, endResearchSession } from './orchestration/session-state.ts';
|
|
799
|
+
import { clearAllSessionState, endResearchSession, getFailedResearchers, getResearcherFailureReasons } from './orchestration/session-state.ts';
|
|
748
800
|
import { shutdownManager } from './utils/shutdown-manager.ts';
|
|
749
801
|
|
|
750
802
|
/**
|
|
@@ -833,6 +885,7 @@ async function _doShutdown(): Promise<void> {
|
|
|
833
885
|
_lastResearchId = null;
|
|
834
886
|
_lastRunSummary = null;
|
|
835
887
|
_lastErrorReport = null;
|
|
888
|
+
_lastResearcherOutcome = null;
|
|
836
889
|
// The module-level config cache survives the globals above. Without this, a
|
|
837
890
|
// shutdown → env/config-file change → re-init cycle in one process silently
|
|
838
891
|
// reuses the first init's config. (The CLI/skill are unaffected — they pass
|
package/src/youtube/index.ts
CHANGED
|
@@ -126,10 +126,11 @@ function formatResults(results: VideoTranscript[], rejected: string[]): string {
|
|
|
126
126
|
// citation pipeline (parseCitations extracts URLs), so the transcript's
|
|
127
127
|
// provenance would silently drop out of the final report's CITED LINKS.
|
|
128
128
|
// The title/channel follows so the report names the real video, not a
|
|
129
|
-
// paraphrase (the watch URL alone is not human-identifiable).
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
129
|
+
// paraphrase (the watch URL alone is not human-identifiable). Emit this line
|
|
130
|
+
// unconditionally — an untitled video still needs a parseable "Cite as" the
|
|
131
|
+
// model can copy verbatim; omitting the line entirely for untitled videos
|
|
132
|
+
// left them without any prompt-visible citation string to reuse.
|
|
133
|
+
md += `**Cite as:** ${r.url}${r.title ? ` — '${r.title}'${r.author ? ` by ${r.author}` : ''}` : ''}\n`;
|
|
133
134
|
md += `\n${r.text}\n\n---\n\n`;
|
|
134
135
|
}
|
|
135
136
|
|