@lincoln504/pi-research 1.0.5 → 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.
@@ -73,6 +73,7 @@ Research
73
73
  | `PI_RESEARCH_MAX_CONCURRENT_SCRAPES` | `3` | 1–20 | Concurrent URLs fetched per scrape batch. |
74
74
  | `PI_RESEARCH_MAX_RETRIES` | `2` | 0–5 | Retries per researcher request. |
75
75
  | `PI_RESEARCH_RETRY_DELAY_MS` | `2000` | 100–10000 | Base delay between retries. |
76
+ | `PI_RESEARCH_MAX_FAILED_RESEARCHERS` | `2` | 1–10 | Unique researcher failures that abort the whole run. Raise to let slower, still-in-flight researchers finish before giving up. |
76
77
  | `PI_RESEARCH_WORKER_THREADS` | `4` | 1–10 | Browser worker processes. Higher = more throughput, more CPU/RAM. |
77
78
  | `PI_RESEARCH_WORKER_CONCURRENCY` | `2` | 1–10 | Tasks per worker process. |
78
79
  | `PI_RESEARCH_MODEL` | _(pi: session model; CLI/skill: required)_ | — | The model research runs on. **Required for the standalone CLI / agent skill** — they use only this configured model (never the model selected inside the pi extension) and refuse to start without one (the CLI's per-run `--model` flag also satisfies this). On the SDK it selects the session model when no `model` option is given. In the pi extension it overrides researcher sub-agents and knowledge synthesis, while the coordinator and evaluator keep using the session model. Accepts `provider/id` or a bare model id. |
package/docs/SDK.md CHANGED
@@ -76,7 +76,10 @@ await shutdownResearchSDK();
76
76
  `runResearchDetailed`, `searchKnowledge`, `scrapeUrl`, `getResearchHealth`,
77
77
  `getLastRunStats`, and `getSessionMetrics`, plus `exportKnowledge` (write the
78
78
  knowledge store to a web-consumable JSON file) and the post-run telemetry accessors
79
- `getLastRunMetrics`, `getLastRunSummary`, and `getLastErrorReport`. Both
79
+ `getLastRunMetrics`, `getLastRunSummary`, `getLastErrorReport`, and
80
+ `getLastResearcherOutcome` (planned/launched/succeeded/failed researcher counts plus
81
+ per-researcher failure reasons for the most recent run — lets a caller tell a thin
82
+ report caused by a sparse topic apart from one where most researchers failed). Both
80
83
  `@lincoln504/pi-research` and `@lincoln504/pi-research/sdk` export these symbols.
81
84
 
82
85
  > Concurrency: a single initialized SDK instance runs one research call at a time.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.0.5",
3
+ "version": "1.0.8",
4
4
  "description": "A set of web research tools (search, scrape, and others) and research agent system that uses them safely and efficiently.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -37,7 +37,8 @@
37
37
  "scripts/prepare.cjs",
38
38
  "LICENSE",
39
39
  "docs/THIRD-PARTY-NOTICES.md",
40
- "README.md"
40
+ "README.md",
41
+ "docs/CHANGELOG.md"
41
42
  ],
42
43
  "repository": {
43
44
  "type": "git",
@@ -109,9 +110,9 @@
109
110
  "image": "https://raw.githubusercontent.com/Lincoln504/pi-research/main/docs/media/hero.png"
110
111
  },
111
112
  "dependencies": {
112
- "@earendil-works/pi-ai": ">=0.80.2 <1",
113
- "@earendil-works/pi-coding-agent": ">=0.80.2 <1",
114
- "@earendil-works/pi-tui": ">=0.80.2 <1",
113
+ "@earendil-works/pi-ai": ">=0.80.7 <1",
114
+ "@earendil-works/pi-coding-agent": ">=0.80.7 <1",
115
+ "@earendil-works/pi-tui": ">=0.80.7 <1",
115
116
  "@huggingface/transformers": "4.2.0",
116
117
  "@kreuzberg/html-to-markdown-node": "^3.5.5",
117
118
  "@lancedb/lancedb": "0.29.0",
@@ -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", "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) */
@@ -24,16 +24,23 @@ export type { HeadlessObserverOptions };
24
24
  * rejections never become unhandled.
25
25
  */
26
26
  export function makeSafeObserver<T extends object>(observer: T): T {
27
- return new Proxy(observer, {
28
- get(target, prop) {
29
- // Guard the ACCESS too, and resolve getters against the target (not the
30
- // proxy): an observer exposing a method via an accessor that touches a
31
- // private field would otherwise throw at `observer?.onX?.(...)` property
32
- // access — outside the call-site try/catch below — which is exactly the
33
- // failure this wrapper exists to absorb.
27
+ // Proxy a FRESH empty object and close over the real observer instead of
28
+ // proxying the observer directly: a `get` trap on the observer itself may
29
+ // not substitute a wrapper for a non-writable non-configurable own data
30
+ // property (Proxy invariant), so a frozen observer — a common defensive
31
+ // pattern for SDK callback bags — would make the engine throw TypeError at
32
+ // the property ACCESS, outside every try/catch. An empty target has no own
33
+ // properties, so no invariant can ever bind the trap.
34
+ return new Proxy({} as T, {
35
+ get(_target, prop) {
36
+ // Guard the ACCESS too, and resolve getters against the observer (not
37
+ // the proxy): an observer exposing a method via an accessor that touches
38
+ // a private field would otherwise throw at `observer?.onX?.(...)`
39
+ // property access — outside the call-site try/catch below — which is
40
+ // exactly the failure this wrapper exists to absorb.
34
41
  let value: unknown;
35
42
  try {
36
- value = Reflect.get(target, prop, target);
43
+ value = Reflect.get(observer, prop, observer);
37
44
  } catch (err) {
38
45
  logger.warn(`[observer] Ignored error reading observer.${String(prop)}:`, err);
39
46
  return undefined;
@@ -41,7 +48,7 @@ export function makeSafeObserver<T extends object>(observer: T): T {
41
48
  if (typeof value !== 'function') return value;
42
49
  return (...args: unknown[]) => {
43
50
  try {
44
- const out = value.apply(target, args);
51
+ const out = value.apply(observer, args);
45
52
  if (out && typeof (out as Promise<unknown>).then === 'function') {
46
53
  return (out as Promise<unknown>).catch((err: unknown) => {
47
54
  logger.warn(`[observer] Ignored rejection from observer.${String(prop)}:`, err);
@@ -54,6 +61,17 @@ export function makeSafeObserver<T extends object>(observer: T): T {
54
61
  }
55
62
  };
56
63
  },
64
+ has(_target, prop) {
65
+ try {
66
+ return Reflect.has(observer, prop);
67
+ } catch {
68
+ return false;
69
+ }
70
+ },
71
+ // Keep `instanceof` transparent (legal on an extensible target).
72
+ getPrototypeOf() {
73
+ return Reflect.getPrototypeOf(observer);
74
+ },
57
75
  }) as T;
58
76
  }
59
77
 
@@ -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.