@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.
@@ -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.6", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
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
- toStdout(pretty({ ok: true, depth, report, ...(reportPath ? { reportPath } : {}) }));
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
@@ -53,6 +53,8 @@ export {
53
53
  getLastRunSummary,
54
54
  getLastErrorReport,
55
55
  getLastRunStats,
56
+ getLastResearcherOutcome,
57
+ type ResearcherOutcome,
56
58
  getSessionMetrics,
57
59
  getResearchHealth,
58
60
  runQuickResearch,
@@ -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(true, container);
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(true, container);
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(true, container);
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, getResearchStopMessage } from './session-state.ts';
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 new Error(getResearchStopMessage(sessionId, researchId));
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
- const stopMessage = getResearchStopMessage(sessionId, researchId);
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: if the reports yielded no parseable citations — e.g. the
182
- // LLM omitted or mangled its "CITED LINKS" block, or wrote the sources under a
183
- // heading parseCitations does not recognize — rebuild the sources list from the
184
- // ground-truth provenance: the URLs the researchers ACTUALLY, successfully
185
- // scraped this session (registerScrapedLinks only records HTTP-success fetches).
186
- // This decouples the final sources list from fragile LLM prose formatting so that
187
- // real fetched sources are never silently lost.
188
- if (globalCitations.length === 0) {
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
- // Label per actual provenance: the pool also contains transcript watch
192
- // URLs the youtube_transcript tool registered — calling those "Scrape"
193
- // misattributes the report's real source material.
194
- globalCitations = scraped.map((url, i) => ({
195
- id: i + 1,
196
- url,
197
- description: '',
198
- source: isTranscribedLink(sessionId, url) ? 'YouTube Transcript' : 'Scrape',
199
- }));
200
- logger.warn(
201
- `[ResearchSynthesisService] No citations parsed from reports; rebuilt ${scraped.length} source(s) from scrape/transcript provenance.`,
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
- if (initialLinks.length === 0 && historicalUrls.length === 0) {
66
- logger.warn(`[ResearcherExecutor] Researcher ${id} has no initial search results or historical links; skipping.`);
67
- // Use the resolved sessionId (= piSessionId), NOT the raw ctx.sessionId: when
68
- // ctx.sessionId is unset but sessionManager supplies a real id (common in SDK
69
- // use), recording under ctx.sessionId would file this no-links skip under a
70
- // different id than shouldStopResearch() checks, so the fast-stop guard would
71
- // under-count exactly the broad-search-failure case it exists to catch.
72
- recordResearcherFailure(sessionId, researchId, id, 'no initial search results or historical links (search produced nothing usable)');
73
- metrics.increment('researcher_skipped_total', 1, { mode: 'deep', complexity: String(complexity), reason: 'no_initial_links' });
74
- observer?.onResearcherFailure?.(id, 'No initial search results or historical links available');
75
- return;
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 (initialLinks.length > 0) {
80
- evidenceSection = `## Evidence Provided\nInitial search results provided the following URLs to investigate:\n${initialLinks.map(l => `- ${l}`).join('\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
- * Maximum allowed unique failed researchers
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 MAX_FAILED_RESEARCHERS = 2;
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 >= MAX_FAILED_RESEARCHERS;
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
@@ -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
- if (r.title) {
131
- md += `**Cite as:** ${r.url} — '${r.title}'${r.author ? ` by ${r.author}` : ''}\n`;
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