@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.
- package/dist/cli.mjs +210 -40
- package/dist/prompts/research-tool-usage.md +1 -0
- package/dist/thread-worker.mjs +2 -0
- package/docs/ARCHITECTURE.md +23 -7
- 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/headless-observer.ts +27 -9
- 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
package/docs/CONFIGURATION.md
CHANGED
|
@@ -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`,
|
|
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.
|
|
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.
|
|
113
|
-
"@earendil-works/pi-coding-agent": ">=0.80.
|
|
114
|
-
"@earendil-works/pi-tui": ">=0.80.
|
|
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
|
+
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) */
|
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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(
|
|
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(
|
|
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,
|
|
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.
|