@lincoln504/pi-research 1.0.3 → 1.0.4
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 +67 -18
- package/docs/CONFIGURATION.md +1 -1
- package/package.json +1 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/skills/pi-research/references/configuration.md +2 -2
- package/src/cli.ts +51 -16
- package/src/core/llm/model-registry-factory.ts +17 -6
- package/src/orchestration/deep-research-orchestrator.ts +17 -2
- package/src/orchestration/research-orchestration-service.ts +3 -2
- package/src/orchestration/researcher-executor.ts +1 -1
- package/src/orchestration/session-state.ts +33 -1
- package/src/sdk.ts +13 -0
package/dist/cli.mjs
CHANGED
|
@@ -3252,7 +3252,7 @@ function buildModelRegistry(apiKey, provider) {
|
|
|
3252
3252
|
fs5.existsSync(modelsJsonPath) ? modelsJsonPath : void 0
|
|
3253
3253
|
);
|
|
3254
3254
|
}
|
|
3255
|
-
if (fs5.existsSync(authPath)) {
|
|
3255
|
+
if (fs5.existsSync(authPath) || fs5.existsSync(modelsJsonPath)) {
|
|
3256
3256
|
const authStorage = AuthStorage.create(authPath);
|
|
3257
3257
|
return ModelRegistry.create(
|
|
3258
3258
|
authStorage,
|
|
@@ -3266,7 +3266,17 @@ function constructMinimalModel(provider, modelId, _apiKey) {
|
|
|
3266
3266
|
provider,
|
|
3267
3267
|
id: modelId,
|
|
3268
3268
|
name: modelId,
|
|
3269
|
-
|
|
3269
|
+
// Map to a REAL pi-ai api id — "<provider>-conversations" is not one, and a
|
|
3270
|
+
// keyed no-registry setup would pass pre-flight then die on the first call.
|
|
3271
|
+
// Unknown providers default to the OpenAI-compatible protocol, which is what
|
|
3272
|
+
// virtually every third-party endpoint (openrouter, groq, cerebras, …) speaks.
|
|
3273
|
+
api: {
|
|
3274
|
+
openai: "openai-completions",
|
|
3275
|
+
anthropic: "anthropic-messages",
|
|
3276
|
+
google: "google-generative-ai",
|
|
3277
|
+
gemini: "google-generative-ai",
|
|
3278
|
+
mistral: "mistral-conversations"
|
|
3279
|
+
}[provider] ?? "openai-completions",
|
|
3270
3280
|
baseUrl: "",
|
|
3271
3281
|
// Provider-specific base URLs are handled by pi-ai internal registry
|
|
3272
3282
|
reasoning: false,
|
|
@@ -19812,6 +19822,7 @@ function getPiState(piSessionId) {
|
|
|
19812
19822
|
if (!state) {
|
|
19813
19823
|
state = {
|
|
19814
19824
|
failures: /* @__PURE__ */ new Map(),
|
|
19825
|
+
failureReasons: /* @__PURE__ */ new Map(),
|
|
19815
19826
|
order: [],
|
|
19816
19827
|
panels: /* @__PURE__ */ new Map(),
|
|
19817
19828
|
aborts: /* @__PURE__ */ new Map(),
|
|
@@ -19883,6 +19894,7 @@ function refreshAllSessions(piSessionId) {
|
|
|
19883
19894
|
for (const id of state.order) {
|
|
19884
19895
|
if (!validIds.includes(id)) {
|
|
19885
19896
|
state.failures.delete(id);
|
|
19897
|
+
state.failureReasons.delete(id);
|
|
19886
19898
|
state.panels.delete(id);
|
|
19887
19899
|
}
|
|
19888
19900
|
}
|
|
@@ -19914,6 +19926,7 @@ function endResearchSession(piSessionId, researchId) {
|
|
|
19914
19926
|
const state = piSessions.get(sid);
|
|
19915
19927
|
if (!state) return;
|
|
19916
19928
|
state.failures.delete(researchId);
|
|
19929
|
+
state.failureReasons.delete(researchId);
|
|
19917
19930
|
state.panels.delete(researchId);
|
|
19918
19931
|
state.aborts.delete(researchId);
|
|
19919
19932
|
const index = state.order.indexOf(researchId);
|
|
@@ -19931,6 +19944,7 @@ function endResearchSession(piSessionId, researchId) {
|
|
|
19931
19944
|
piSessions.delete(sid);
|
|
19932
19945
|
} else {
|
|
19933
19946
|
state.failures.clear();
|
|
19947
|
+
state.failureReasons.clear();
|
|
19934
19948
|
state.aborts.clear();
|
|
19935
19949
|
}
|
|
19936
19950
|
}
|
|
@@ -19952,12 +19966,23 @@ function clearAllSessionState() {
|
|
|
19952
19966
|
clearAllSharedLinks();
|
|
19953
19967
|
logger.log("[session-state] All session state cleared");
|
|
19954
19968
|
}
|
|
19955
|
-
function recordResearcherFailure(piSessionId, researchId, researcherId) {
|
|
19969
|
+
function recordResearcherFailure(piSessionId, researchId, researcherId, reason) {
|
|
19956
19970
|
const sid = normalizeSessionId(piSessionId);
|
|
19957
19971
|
const state = getPiState(sid);
|
|
19958
19972
|
const failures = state.failures.get(researchId) || [];
|
|
19959
19973
|
failures.push(researcherId);
|
|
19960
19974
|
state.failures.set(researchId, failures);
|
|
19975
|
+
if (reason) {
|
|
19976
|
+
const reasons = state.failureReasons.get(researchId) ?? /* @__PURE__ */ new Map();
|
|
19977
|
+
if (!reasons.has(researcherId)) reasons.set(researcherId, reason);
|
|
19978
|
+
state.failureReasons.set(researchId, reasons);
|
|
19979
|
+
}
|
|
19980
|
+
}
|
|
19981
|
+
function getResearcherFailureReasons(piSessionId, researchId) {
|
|
19982
|
+
const sid = normalizeSessionId(piSessionId);
|
|
19983
|
+
const state = getPiState(sid);
|
|
19984
|
+
const reasons = state.failureReasons.get(researchId);
|
|
19985
|
+
return reasons ? Object.fromEntries(reasons) : {};
|
|
19961
19986
|
}
|
|
19962
19987
|
function getFailedResearchers(piSessionId, researchId) {
|
|
19963
19988
|
const sid = normalizeSessionId(piSessionId);
|
|
@@ -19974,9 +19999,13 @@ function getResearchStopMessage(piSessionId, researchId) {
|
|
|
19974
19999
|
const sid = normalizeSessionId(piSessionId);
|
|
19975
20000
|
const failed = getFailedResearchers(sid, researchId);
|
|
19976
20001
|
const count = failed.length;
|
|
20002
|
+
const state = getPiState(sid);
|
|
20003
|
+
const reasons = state.failureReasons.get(researchId);
|
|
20004
|
+
const recorded = reasons && reasons.size > 0 ? ["Recorded errors:", ...[...reasons].map(([id, r]) => `\u2022 researcher ${id}: ${r}`), ""] : [];
|
|
19977
20005
|
return [
|
|
19978
20006
|
`Research stopped: ${count} researcher(s) did not return a usable report: ${failed.join(", ")}.`,
|
|
19979
20007
|
"",
|
|
20008
|
+
...recorded,
|
|
19980
20009
|
"Most likely cause \u2014 the research model could not produce grounded results:",
|
|
19981
20010
|
'\u2022 Try a more capable research model. Very small or "thinking-only" models often',
|
|
19982
20011
|
" emit no final text after their tool calls, which fails every researcher.",
|
|
@@ -20026,7 +20055,7 @@ ${currentPlan.allQueries.map((q) => `- ${q}`).join("\n")}
|
|
|
20026
20055
|
const researcherPromptTemplate = loadPrompt("researcher");
|
|
20027
20056
|
if (initialLinks.length === 0 && historicalUrls.length === 0) {
|
|
20028
20057
|
logger.warn(`[ResearcherExecutor] Researcher ${id} has no initial search results or historical links; skipping.`);
|
|
20029
|
-
recordResearcherFailure(sessionId, researchId, id);
|
|
20058
|
+
recordResearcherFailure(sessionId, researchId, id, "no initial search results or historical links (search produced nothing usable)");
|
|
20030
20059
|
metrics.increment("researcher_skipped_total", 1, { mode: "deep", complexity: String(complexity), reason: "no_initial_links" });
|
|
20031
20060
|
observer?.onResearcherFailure?.(id, "No initial search results or historical links available");
|
|
20032
20061
|
return;
|
|
@@ -20966,7 +20995,12 @@ var DeepResearchOrchestrator = class {
|
|
|
20966
20995
|
logger.warn(`[DeepOrchestrator] LLM returned empty synthesis for ${researchId}, building fallback from ${synthesisService2.getReportCount(researchId)} reports`);
|
|
20967
20996
|
result = synthesisService2.buildFallbackSynthesis(researchId, this.currentRound);
|
|
20968
20997
|
} else {
|
|
20969
|
-
|
|
20998
|
+
const failedIds = getFailedResearchers(this.options.sessionId, researchId);
|
|
20999
|
+
const reasons = getResearcherFailureReasons(this.options.sessionId, researchId);
|
|
21000
|
+
const causes = failedIds.map((id) => `researcher ${id}: ${reasons[id] ?? "no usable report"}`).join("; ");
|
|
21001
|
+
throw new Error(
|
|
21002
|
+
"Research produced no report \u2014 no source material was collected" + (causes ? `. Causes: ${causes}` : ".")
|
|
21003
|
+
);
|
|
20970
21004
|
}
|
|
20971
21005
|
}
|
|
20972
21006
|
try {
|
|
@@ -21268,7 +21302,7 @@ var ResearchOrchestrationService = class {
|
|
|
21268
21302
|
logger.debug(`[ResearchOrchestrationService] Researcher ${id} cancelled (aborted).`);
|
|
21269
21303
|
} else {
|
|
21270
21304
|
logger.error(`[ResearchOrchestrationService] Researcher ${id} failed: ${errMsg}`);
|
|
21271
|
-
recordResearcherFailure(sessionId, researchId, id);
|
|
21305
|
+
recordResearcherFailure(sessionId, researchId, id, errMsg);
|
|
21272
21306
|
observer?.onResearcherFailure?.(id, errMsg);
|
|
21273
21307
|
}
|
|
21274
21308
|
}
|
|
@@ -22000,6 +22034,10 @@ async function _doInit(options = {}) {
|
|
|
22000
22034
|
}
|
|
22001
22035
|
globalApiKey = options.apiKey || process.env["PI_RESEARCH_API_KEY"];
|
|
22002
22036
|
let parsedProvider = options.provider || process.env["PI_RESEARCH_PROVIDER"];
|
|
22037
|
+
if (options.model) {
|
|
22038
|
+
const explicit = typeof options.model === "string" ? options.model : `${options.model.provider}/${options.model.id}`;
|
|
22039
|
+
globalConfig = { ...globalConfig, RESEARCH_MODEL: explicit };
|
|
22040
|
+
}
|
|
22003
22041
|
const effectiveModel = options.model ?? globalConfig.RESEARCH_MODEL;
|
|
22004
22042
|
if (!parsedProvider && effectiveModel) {
|
|
22005
22043
|
if (typeof effectiveModel === "string") {
|
|
@@ -22475,6 +22513,7 @@ Total cost: ${costStr}`;
|
|
|
22475
22513
|
|
|
22476
22514
|
// src/cli.ts
|
|
22477
22515
|
init_config();
|
|
22516
|
+
init_model_registry_factory();
|
|
22478
22517
|
import { getAgentDir as getAgentDir2 } from "@earendil-works/pi-coding-agent";
|
|
22479
22518
|
function readPkgVersion() {
|
|
22480
22519
|
try {
|
|
@@ -22593,6 +22632,15 @@ function fileExists(p) {
|
|
|
22593
22632
|
return false;
|
|
22594
22633
|
}
|
|
22595
22634
|
}
|
|
22635
|
+
function piKeysAvailable(piAuthPresent, provider) {
|
|
22636
|
+
try {
|
|
22637
|
+
const available = safeGetAvailable(buildModelRegistry(void 0, void 0));
|
|
22638
|
+
if (!provider) return available.length > 0;
|
|
22639
|
+
return available.some((m) => m.provider === provider);
|
|
22640
|
+
} catch {
|
|
22641
|
+
return piAuthPresent;
|
|
22642
|
+
}
|
|
22643
|
+
}
|
|
22596
22644
|
function detectCredentials() {
|
|
22597
22645
|
const paths = resolvedConfigPaths();
|
|
22598
22646
|
const piAuthPresent = fileExists(paths.piAuth);
|
|
@@ -22604,6 +22652,7 @@ function detectCredentials() {
|
|
|
22604
22652
|
const model = modelEnv ?? modelConfig;
|
|
22605
22653
|
const modelFrom = modelEnv ? "PI_RESEARCH_MODEL" : modelConfig ? "config.env" : "unset";
|
|
22606
22654
|
const provider = providerEnv ?? (model && model.includes("/") ? model.slice(0, model.indexOf("/")) : void 0);
|
|
22655
|
+
const piKeysUsable = piKeysAvailable(piAuthPresent, provider);
|
|
22607
22656
|
if (apiKey) {
|
|
22608
22657
|
if (!provider) {
|
|
22609
22658
|
return {
|
|
@@ -22639,7 +22688,7 @@ function detectCredentials() {
|
|
|
22639
22688
|
piModelsPresent
|
|
22640
22689
|
};
|
|
22641
22690
|
}
|
|
22642
|
-
if (
|
|
22691
|
+
if (piKeysUsable) {
|
|
22643
22692
|
if (!model) {
|
|
22644
22693
|
return {
|
|
22645
22694
|
source: "pi-config",
|
|
@@ -22649,13 +22698,13 @@ function detectCredentials() {
|
|
|
22649
22698
|
modelFrom,
|
|
22650
22699
|
piAuthPresent,
|
|
22651
22700
|
piModelsPresent,
|
|
22652
|
-
problem:
|
|
22701
|
+
problem: `pi credentials were found (in pi's configuration), but no research model is configured. The standalone CLI / agent skill run only on an explicitly configured model \u2014 they do not follow the model selected inside the pi extension. Set PI_RESEARCH_MODEL to a "provider/model-id" (in the environment, or in config.env / cli.env below).`
|
|
22653
22702
|
};
|
|
22654
22703
|
}
|
|
22655
22704
|
return {
|
|
22656
22705
|
source: "pi-config",
|
|
22657
22706
|
apiKeyConfigured: true,
|
|
22658
|
-
// key lives in
|
|
22707
|
+
// key lives in pi's configuration; the SDK reads it at call time
|
|
22659
22708
|
provider,
|
|
22660
22709
|
model,
|
|
22661
22710
|
modelFrom,
|
|
@@ -22671,7 +22720,7 @@ function detectCredentials() {
|
|
|
22671
22720
|
modelFrom,
|
|
22672
22721
|
piAuthPresent,
|
|
22673
22722
|
piModelsPresent,
|
|
22674
|
-
problem: "No model or API key is configured for pi-research. Provide credentials via environment variables (PI_RESEARCH_API_KEY + PI_RESEARCH_PROVIDER + PI_RESEARCH_MODEL) or, if you use pi, via ~/.pi/agent/auth.json."
|
|
22723
|
+
problem: "No model or API key is configured for pi-research. Provide credentials via environment variables (PI_RESEARCH_API_KEY + PI_RESEARCH_PROVIDER + PI_RESEARCH_MODEL) or, if you use pi, via pi's configuration (a provider entry in ~/.pi/agent/auth.json, or a per-provider apiKey in ~/.pi/agent/models.json)."
|
|
22675
22724
|
};
|
|
22676
22725
|
}
|
|
22677
22726
|
function configBlock(det, extraNote) {
|
|
@@ -23035,7 +23084,7 @@ function reportError(err, what, json) {
|
|
|
23035
23084
|
const lower = msg2.toLowerCase();
|
|
23036
23085
|
const isConfigError = lower.includes("no llm model available") || lower.includes("not found in pi") || lower.includes("no api key") || lower.includes("provider must be specified") || lower.includes("invalid model string") || lower.includes("authentication") || lower.includes("unauthorized") || lower.includes("api key");
|
|
23037
23086
|
const isRateLimit = lower.includes("429") || lower.includes("rate limit") || lower.includes("too many requests");
|
|
23038
|
-
const exitCode = isConfigError ? EXIT.CONFIG : EXIT.SOFTWARE;
|
|
23087
|
+
const exitCode = isConfigError && !isRateLimit ? EXIT.CONFIG : EXIT.SOFTWARE;
|
|
23039
23088
|
if (json) {
|
|
23040
23089
|
const payload = { ok: false, error: msg2, exitCode };
|
|
23041
23090
|
if (process.env["PI_RESEARCH_DEBUG"] === "true" && err instanceof Error && err.stack) {
|
|
@@ -23044,18 +23093,18 @@ function reportError(err, what, json) {
|
|
|
23044
23093
|
toStdout(pretty(payload));
|
|
23045
23094
|
return exitCode;
|
|
23046
23095
|
}
|
|
23047
|
-
if (
|
|
23096
|
+
if (isRateLimit) {
|
|
23048
23097
|
toStderr(`
|
|
23049
|
-
Error: pi-research ${what}
|
|
23050
|
-
|
|
23051
|
-
${configBlock(detectCredentials())}
|
|
23098
|
+
Error: pi-research ${what} halted: provider rate limit reached. Wait a moment and retry.
|
|
23099
|
+
${msg2}
|
|
23052
23100
|
`);
|
|
23053
23101
|
return exitCode;
|
|
23054
23102
|
}
|
|
23055
|
-
if (
|
|
23103
|
+
if (isConfigError) {
|
|
23056
23104
|
toStderr(`
|
|
23057
|
-
Error: pi-research ${what}
|
|
23058
|
-
|
|
23105
|
+
Error: pi-research ${what} failed: ${msg2}
|
|
23106
|
+
|
|
23107
|
+
${configBlock(detectCredentials())}
|
|
23059
23108
|
`);
|
|
23060
23109
|
return exitCode;
|
|
23061
23110
|
}
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -132,7 +132,7 @@ API keys
|
|
|
132
132
|
|
|
133
133
|
| Variable | Description |
|
|
134
134
|
|----------|-------------|
|
|
135
|
-
| `PI_RESEARCH_API_KEY` / `PI_RESEARCH_PROVIDER` | Explicit LLM credentials for SDK / CLI mode (not needed when pi's configuration
|
|
135
|
+
| `PI_RESEARCH_API_KEY` / `PI_RESEARCH_PROVIDER` | Explicit LLM credentials for SDK / CLI mode (not needed when pi's configuration supplies the key). On the CLI / agent skill both can also live in `config.env` / `cli.env`. The provider is required alongside the key, or inferred from a `provider/model-id` `PI_RESEARCH_MODEL`. |
|
|
136
136
|
| `STACKEXCHANGE_API_KEY` | Raises the Stack Exchange tool's limit from 300/day to 10,000/day. Obtain at <https://stackapps.com/apps/oauth>. |
|
|
137
137
|
| `GITHUB_TOKEN` | Raises the security tool's GitHub Advisory limit from 60/hr to 5000/hr (any default-scope token). |
|
|
138
138
|
| `NVD_API_KEY` | Raises the security tool's NVD limit ~10× and tightens request spacing. Request at <https://nvd.nist.gov/developers/request-an-api-key>. Recommended when using severity-filtered security searches: those issue a second (CVSS v2) NVD query to catch v2-only CVEs, which roughly doubles request time against the 6 s/request unauthenticated throttle. |
|
package/package.json
CHANGED
|
@@ -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.4", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
|
|
6
6
|
allowed-tools: Bash(node:*)
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -15,8 +15,8 @@ to configure (no research runs).
|
|
|
15
15
|
export PI_RESEARCH_MODEL=openai/gpt-4o # provider/model-id
|
|
16
16
|
```
|
|
17
17
|
2. **Global config** `~/.pi/research/config.env` (same KEY=VALUE keys; read at startup).
|
|
18
|
-
3. **pi configuration** (if you use `pi`): the API key comes from
|
|
19
|
-
automatically — `PI_RESEARCH_MODEL` must still be set (env or `config.env`).
|
|
18
|
+
3. **pi configuration** (if you use `pi`): the API key comes from your pi
|
|
19
|
+
configuration automatically — `PI_RESEARCH_MODEL` must still be set (env or `config.env`).
|
|
20
20
|
|
|
21
21
|
Real env vars always win. Optional CLI-only overlay `~/.pi/research/cli.env` layers
|
|
22
22
|
over `config.env` for the CLI only. Precedence:
|
package/src/cli.ts
CHANGED
|
@@ -39,6 +39,7 @@ import { validateInitialLink, MAX_INITIAL_LINKS } from './utils/url-utils.ts';
|
|
|
39
39
|
import { exportResearchReport, appendExportMessage } from './utils/research-export.ts';
|
|
40
40
|
import { getConfig, getGlobalConfigDir, getGlobalEnvFilePath, getInterfaceEnvFilePath, saveConfig, resetConfig, describeKnowledgeStoreMode, isProjectScopedKey, parseDotEnv } from './config.ts';
|
|
41
41
|
import { getAgentDir } from '@earendil-works/pi-coding-agent';
|
|
42
|
+
import { buildModelRegistry, safeGetAvailable } from './core/llm/model-registry-factory.ts';
|
|
42
43
|
|
|
43
44
|
// ---------------------------------------------------------------------------
|
|
44
45
|
// Constants
|
|
@@ -226,6 +227,32 @@ function fileExists(p: string): boolean {
|
|
|
226
227
|
}
|
|
227
228
|
}
|
|
228
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Are keys usable from pi's configuration? Answered by pi's OWN semantics, not
|
|
232
|
+
* by file presence: build pi's model registry (two small JSON reads — no
|
|
233
|
+
* services, network, or native deps) and ask for the authed subset. That check
|
|
234
|
+
* (`hasConfiguredAuth`) covers every source the registry consults at call
|
|
235
|
+
* time: an auth.json provider entry (api-key or OAuth), a per-provider apiKey
|
|
236
|
+
* embedded in models.json (env-template aware), and provider env vars like
|
|
237
|
+
* OPENAI_API_KEY. File presence alone is neither necessary (keys can live in
|
|
238
|
+
* models.json / env with no auth.json) nor sufficient (auth.json can be `{}`).
|
|
239
|
+
*/
|
|
240
|
+
function piKeysAvailable(piAuthPresent: boolean, provider?: string): boolean {
|
|
241
|
+
try {
|
|
242
|
+
const available = safeGetAvailable(buildModelRegistry(undefined, undefined));
|
|
243
|
+
if (!provider) return available.length > 0;
|
|
244
|
+
// Provider known (from the configured provider/model-id): keys must exist
|
|
245
|
+
// for THAT provider — a key for some other provider would pass pre-flight
|
|
246
|
+
// and then die on the first LLM call ("No API key found for <provider>").
|
|
247
|
+
return available.some((m) => m.provider === provider);
|
|
248
|
+
} catch {
|
|
249
|
+
// Cannot inspect (e.g. malformed models.json) — fall back to file
|
|
250
|
+
// presence: pre-flight must never block a possibly-working setup; the run
|
|
251
|
+
// itself reports the precise error.
|
|
252
|
+
return piAuthPresent;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
229
256
|
export interface CredentialDetection {
|
|
230
257
|
/** Where credentials will come from once initResearchSDK runs. */
|
|
231
258
|
source: 'explicit-env' | 'pi-config' | 'none';
|
|
@@ -258,6 +285,9 @@ function detectCredentials(): CredentialDetection {
|
|
|
258
285
|
// Provider is either explicit, or inferred from a "provider/id" model string.
|
|
259
286
|
const provider =
|
|
260
287
|
providerEnv ?? (model && model.includes('/') ? model.slice(0, model.indexOf('/')) : undefined);
|
|
288
|
+
// Provider-aware when the configured model names one: a key for a DIFFERENT
|
|
289
|
+
// provider must not greenlight this run.
|
|
290
|
+
const piKeysUsable = piKeysAvailable(piAuthPresent, provider);
|
|
261
291
|
|
|
262
292
|
// --- Explicit API-key path -------------------------------------------------
|
|
263
293
|
if (apiKey) {
|
|
@@ -300,13 +330,13 @@ function detectCredentials(): CredentialDetection {
|
|
|
300
330
|
};
|
|
301
331
|
}
|
|
302
332
|
|
|
303
|
-
// --- pi configuration path (key from auth.json)
|
|
304
|
-
if (
|
|
305
|
-
// Keys come from
|
|
306
|
-
// the standalone CLI / agent skill run only on the configured
|
|
307
|
-
// never follow the model selected inside the pi extension, and
|
|
308
|
-
// falling back to the registry's first authed entry picks a model
|
|
309
|
-
// chose. Fail fast here (pre-flight, before any SDK init).
|
|
333
|
+
// --- pi configuration path (key from auth.json, models.json, or provider env) ---
|
|
334
|
+
if (piKeysUsable) {
|
|
335
|
+
// Keys come from pi's configuration, but the MODEL must be configured
|
|
336
|
+
// explicitly: the standalone CLI / agent skill run only on the configured
|
|
337
|
+
// model — they never follow the model selected inside the pi extension, and
|
|
338
|
+
// silently falling back to the registry's first authed entry picks a model
|
|
339
|
+
// nobody chose. Fail fast here (pre-flight, before any SDK init).
|
|
310
340
|
if (!model) {
|
|
311
341
|
return {
|
|
312
342
|
source: 'pi-config',
|
|
@@ -317,7 +347,7 @@ function detectCredentials(): CredentialDetection {
|
|
|
317
347
|
piAuthPresent,
|
|
318
348
|
piModelsPresent,
|
|
319
349
|
problem:
|
|
320
|
-
'pi credentials were found (
|
|
350
|
+
'pi credentials were found (in pi\'s configuration), but no research model is configured. ' +
|
|
321
351
|
'The standalone CLI / agent skill run only on an explicitly configured model — they do not ' +
|
|
322
352
|
'follow the model selected inside the pi extension. Set PI_RESEARCH_MODEL to a ' +
|
|
323
353
|
'"provider/model-id" (in the environment, or in config.env / cli.env below).',
|
|
@@ -325,7 +355,7 @@ function detectCredentials(): CredentialDetection {
|
|
|
325
355
|
}
|
|
326
356
|
return {
|
|
327
357
|
source: 'pi-config',
|
|
328
|
-
apiKeyConfigured: true, // key lives in
|
|
358
|
+
apiKeyConfigured: true, // key lives in pi's configuration; the SDK reads it at call time
|
|
329
359
|
provider,
|
|
330
360
|
model,
|
|
331
361
|
modelFrom,
|
|
@@ -346,7 +376,8 @@ function detectCredentials(): CredentialDetection {
|
|
|
346
376
|
problem:
|
|
347
377
|
'No model or API key is configured for pi-research. Provide credentials via environment ' +
|
|
348
378
|
'variables (PI_RESEARCH_API_KEY + PI_RESEARCH_PROVIDER + PI_RESEARCH_MODEL) or, if you use ' +
|
|
349
|
-
|
|
379
|
+
"pi, via pi's configuration (a provider entry in ~/.pi/agent/auth.json, or a per-provider " +
|
|
380
|
+
'apiKey in ~/.pi/agent/models.json).',
|
|
350
381
|
};
|
|
351
382
|
}
|
|
352
383
|
|
|
@@ -763,8 +794,12 @@ function reportError(err: unknown, what: string, json?: boolean): number {
|
|
|
763
794
|
lower.includes('unauthorized') ||
|
|
764
795
|
lower.includes('api key');
|
|
765
796
|
|
|
797
|
+
// Rate limits win over config-shaped matches: a provider 429 often mentions
|
|
798
|
+
// "api key" in its body (e.g. "rate limit exceeded for this api key"), and
|
|
799
|
+
// classifying that as a setup problem sends the user to reconfigure a
|
|
800
|
+
// perfectly working key instead of waiting out the limit.
|
|
766
801
|
const isRateLimit = lower.includes('429') || lower.includes('rate limit') || lower.includes('too many requests');
|
|
767
|
-
const exitCode = isConfigError ? EXIT.CONFIG : EXIT.SOFTWARE;
|
|
802
|
+
const exitCode = isConfigError && !isRateLimit ? EXIT.CONFIG : EXIT.SOFTWARE;
|
|
768
803
|
|
|
769
804
|
// --json: emit a structured error on stdout so a machine consumer never has to
|
|
770
805
|
// special-case plain-text stderr. Mirrors the { ok: true, ... } success shape.
|
|
@@ -777,15 +812,15 @@ function reportError(err: unknown, what: string, json?: boolean): number {
|
|
|
777
812
|
return exitCode;
|
|
778
813
|
}
|
|
779
814
|
|
|
780
|
-
|
|
781
|
-
toStderr(`\nError: pi-research ${what} failed: ${msg}\n\n${configBlock(detectCredentials())}\n`);
|
|
782
|
-
return exitCode;
|
|
783
|
-
}
|
|
784
|
-
// Rate limits are operational, not fatal setup problems.
|
|
815
|
+
// Rate limits are operational, not fatal setup problems — checked FIRST.
|
|
785
816
|
if (isRateLimit) {
|
|
786
817
|
toStderr(`\nError: pi-research ${what} halted: provider rate limit reached. Wait a moment and retry.\n ${msg}\n`);
|
|
787
818
|
return exitCode;
|
|
788
819
|
}
|
|
820
|
+
if (isConfigError) {
|
|
821
|
+
toStderr(`\nError: pi-research ${what} failed: ${msg}\n\n${configBlock(detectCredentials())}\n`);
|
|
822
|
+
return exitCode;
|
|
823
|
+
}
|
|
789
824
|
toStderr(`\nError: pi-research ${what} failed: ${msg}\n`);
|
|
790
825
|
if (process.env['PI_RESEARCH_DEBUG'] === 'true' && err instanceof Error && err.stack) {
|
|
791
826
|
toStderr('\n' + err.stack + '\n');
|
|
@@ -123,12 +123,13 @@ export function buildModelRegistry(apiKey?: string, provider?: string): ModelReg
|
|
|
123
123
|
);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
// No explicit key: use the user's pi
|
|
126
|
+
// No explicit key: use the user's pi configuration when EITHER file exists.
|
|
127
127
|
// ModelRegistry.create() reads embedded apiKeys from models.json providers
|
|
128
|
-
// directly (via providerRequestConfigs), so
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
|
|
128
|
+
// directly (via providerRequestConfigs), so custom providers with embedded
|
|
129
|
+
// keys work with no auth.json at all — auth.json presence must not gate
|
|
130
|
+
// models.json (AuthStorage.create tolerates a missing file). Env provider
|
|
131
|
+
// keys (e.g. OPENAI_API_KEY) are honored by AuthStorage in every branch.
|
|
132
|
+
if (fs.existsSync(authPath) || fs.existsSync(modelsJsonPath)) {
|
|
132
133
|
const authStorage = AuthStorage.create(authPath);
|
|
133
134
|
return ModelRegistry.create(
|
|
134
135
|
authStorage,
|
|
@@ -152,7 +153,17 @@ export function constructMinimalModel(provider: string, modelId: string, _apiKey
|
|
|
152
153
|
provider,
|
|
153
154
|
id: modelId,
|
|
154
155
|
name: modelId,
|
|
155
|
-
|
|
156
|
+
// Map to a REAL pi-ai api id — "<provider>-conversations" is not one, and a
|
|
157
|
+
// keyed no-registry setup would pass pre-flight then die on the first call.
|
|
158
|
+
// Unknown providers default to the OpenAI-compatible protocol, which is what
|
|
159
|
+
// virtually every third-party endpoint (openrouter, groq, cerebras, …) speaks.
|
|
160
|
+
api: ({
|
|
161
|
+
openai: 'openai-completions',
|
|
162
|
+
anthropic: 'anthropic-messages',
|
|
163
|
+
google: 'google-generative-ai',
|
|
164
|
+
gemini: 'google-generative-ai',
|
|
165
|
+
mistral: 'mistral-conversations',
|
|
166
|
+
} as Record<string, string>)[provider] ?? 'openai-completions',
|
|
156
167
|
baseUrl: '', // Provider-specific base URLs are handled by pi-ai internal registry
|
|
157
168
|
reasoning: false,
|
|
158
169
|
input: ['text'],
|
|
@@ -9,7 +9,7 @@ import { type Model } from '@earendil-works/pi-ai';
|
|
|
9
9
|
import { logger } from '../logger.ts';
|
|
10
10
|
import { safeUnref } from '../utils/safe-unref.ts';
|
|
11
11
|
import { metrics } from '../utils/metrics.ts';
|
|
12
|
-
import { getSteeringMessages, consumeQueuedMessages, getActiveSteeringMessages } from './session-state.ts';
|
|
12
|
+
import { getSteeringMessages, consumeQueuedMessages, getActiveSteeringMessages, getFailedResearchers, getResearcherFailureReasons } from './session-state.ts';
|
|
13
13
|
import { MAX_EXTRA_ROUNDS_WITH_STEERING } from '../constants.ts';
|
|
14
14
|
import {
|
|
15
15
|
getMaxRounds,
|
|
@@ -486,7 +486,22 @@ export class DeepResearchOrchestrator {
|
|
|
486
486
|
logger.warn(`[DeepOrchestrator] LLM returned empty synthesis for ${researchId}, building fallback from ${synthesisService.getReportCount(researchId)} reports`);
|
|
487
487
|
result = synthesisService.buildFallbackSynthesis(researchId, this.currentRound);
|
|
488
488
|
} else {
|
|
489
|
-
|
|
489
|
+
// ZERO reports were collected — the run produced nothing groundable.
|
|
490
|
+
// With the default single-researcher plan the fast-stop threshold
|
|
491
|
+
// (MAX_FAILED_RESEARCHERS = 2) never trips, so a lone researcher that
|
|
492
|
+
// exhausts its retries (e.g. a provider rate limit) used to land here
|
|
493
|
+
// and return a hollow "no summary was generated" string on the SUCCESS
|
|
494
|
+
// path — exit 0. That is a FAILED run: throw, and surface the recorded
|
|
495
|
+
// root causes so the user sees the 429/model error, not a shrug.
|
|
496
|
+
const failedIds = getFailedResearchers(this.options.sessionId, researchId);
|
|
497
|
+
const reasons = getResearcherFailureReasons(this.options.sessionId, researchId);
|
|
498
|
+
const causes = failedIds
|
|
499
|
+
.map((id) => `researcher ${id}: ${reasons[id] ?? 'no usable report'}`)
|
|
500
|
+
.join('; ');
|
|
501
|
+
throw new Error(
|
|
502
|
+
'Research produced no report — no source material was collected' +
|
|
503
|
+
(causes ? `. Causes: ${causes}` : '.')
|
|
504
|
+
);
|
|
490
505
|
}
|
|
491
506
|
}
|
|
492
507
|
|
|
@@ -358,8 +358,9 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
|
|
|
358
358
|
logger.debug(`[ResearchOrchestrationService] Researcher ${id} cancelled (aborted).`);
|
|
359
359
|
} else {
|
|
360
360
|
logger.error(`[ResearchOrchestrationService] Researcher ${id} failed: ${errMsg}`);
|
|
361
|
-
// Record failure for stopping logic
|
|
362
|
-
|
|
361
|
+
// Record failure (with its root cause) for stopping logic and for the
|
|
362
|
+
// zero-report failure surfaced at synthesis time.
|
|
363
|
+
recordResearcherFailure(sessionId, researchId, id, errMsg);
|
|
363
364
|
// Notify observer
|
|
364
365
|
observer?.onResearcherFailure?.(id, errMsg);
|
|
365
366
|
}
|
|
@@ -69,7 +69,7 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
|
|
|
69
69
|
// use), recording under ctx.sessionId would file this no-links skip under a
|
|
70
70
|
// different id than shouldStopResearch() checks, so the fast-stop guard would
|
|
71
71
|
// under-count exactly the broad-search-failure case it exists to catch.
|
|
72
|
-
recordResearcherFailure(sessionId, researchId, id);
|
|
72
|
+
recordResearcherFailure(sessionId, researchId, id, 'no initial search results or historical links (search produced nothing usable)');
|
|
73
73
|
metrics.increment('researcher_skipped_total', 1, { mode: 'deep', complexity: String(complexity), reason: 'no_initial_links' });
|
|
74
74
|
observer?.onResearcherFailure?.(id, 'No initial search results or historical links available');
|
|
75
75
|
return;
|
|
@@ -26,6 +26,8 @@ const MAX_STEERING_MESSAGE_LENGTH = 2000;
|
|
|
26
26
|
interface PiSessionState {
|
|
27
27
|
/** Map of research run ID → array of failed researcher IDs */
|
|
28
28
|
failures: Map<string, string[]>;
|
|
29
|
+
/** Map of research run ID → (researcher ID → first failure reason) */
|
|
30
|
+
failureReasons: Map<string, Map<string, string>>;
|
|
29
31
|
/** Ordered list of research run IDs for TUI stacking (Index 0 = oldest/bottom-most) */
|
|
30
32
|
order: string[];
|
|
31
33
|
/** Registry of panel states for each research run */
|
|
@@ -70,6 +72,7 @@ function getPiState(piSessionId: string | undefined): PiSessionState {
|
|
|
70
72
|
if (!state) {
|
|
71
73
|
state = {
|
|
72
74
|
failures: new Map(),
|
|
75
|
+
failureReasons: new Map(),
|
|
73
76
|
order: [],
|
|
74
77
|
panels: new Map(),
|
|
75
78
|
aborts: new Map(),
|
|
@@ -392,6 +395,7 @@ export function refreshAllSessions(piSessionId: string | undefined): void {
|
|
|
392
395
|
for (const id of state.order) {
|
|
393
396
|
if (!validIds.includes(id)) {
|
|
394
397
|
state.failures.delete(id);
|
|
398
|
+
state.failureReasons.delete(id);
|
|
395
399
|
state.panels.delete(id);
|
|
396
400
|
}
|
|
397
401
|
}
|
|
@@ -454,6 +458,7 @@ export function startResearchSession(piSessionId: string | undefined, customRese
|
|
|
454
458
|
const sid = normalizeSessionId(piSessionId);
|
|
455
459
|
const state = getPiState(sid);
|
|
456
460
|
state.failures.set(researchId, []);
|
|
461
|
+
state.failureReasons.set(researchId, new Map());
|
|
457
462
|
return researchId;
|
|
458
463
|
}
|
|
459
464
|
|
|
@@ -501,6 +506,7 @@ export function endResearchSession(piSessionId: string | undefined, researchId:
|
|
|
501
506
|
if (!state) return;
|
|
502
507
|
|
|
503
508
|
state.failures.delete(researchId);
|
|
509
|
+
state.failureReasons.delete(researchId);
|
|
504
510
|
state.panels.delete(researchId);
|
|
505
511
|
state.aborts.delete(researchId);
|
|
506
512
|
|
|
@@ -525,6 +531,7 @@ export function endResearchSession(piSessionId: string | undefined, researchId:
|
|
|
525
531
|
} else {
|
|
526
532
|
// Clear research-specific state but preserve the session and steering
|
|
527
533
|
state.failures.clear();
|
|
534
|
+
state.failureReasons.clear();
|
|
528
535
|
state.aborts.clear();
|
|
529
536
|
}
|
|
530
537
|
}
|
|
@@ -563,12 +570,28 @@ export function isBottomMostSession(piSessionId: string | undefined, researchId:
|
|
|
563
570
|
/**
|
|
564
571
|
* Record a researcher failure
|
|
565
572
|
*/
|
|
566
|
-
export function recordResearcherFailure(piSessionId: string | undefined, researchId: string, researcherId: string): void {
|
|
573
|
+
export function recordResearcherFailure(piSessionId: string | undefined, researchId: string, researcherId: string, reason?: string): void {
|
|
567
574
|
const sid = normalizeSessionId(piSessionId);
|
|
568
575
|
const state = getPiState(sid);
|
|
569
576
|
const failures = state.failures.get(researchId) || [];
|
|
570
577
|
failures.push(researcherId);
|
|
571
578
|
state.failures.set(researchId, failures);
|
|
579
|
+
if (reason) {
|
|
580
|
+
const reasons = state.failureReasons.get(researchId) ?? new Map<string, string>();
|
|
581
|
+
// Keep the FIRST reason per researcher — the root cause; retries repeat it.
|
|
582
|
+
if (!reasons.has(researcherId)) reasons.set(researcherId, reason);
|
|
583
|
+
state.failureReasons.set(researchId, reasons);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Get the recorded failure reason per failed researcher (first failure wins).
|
|
589
|
+
*/
|
|
590
|
+
export function getResearcherFailureReasons(piSessionId: string | undefined, researchId: string): Record<string, string> {
|
|
591
|
+
const sid = normalizeSessionId(piSessionId);
|
|
592
|
+
const state = getPiState(sid);
|
|
593
|
+
const reasons = state.failureReasons.get(researchId);
|
|
594
|
+
return reasons ? Object.fromEntries(reasons) : {};
|
|
572
595
|
}
|
|
573
596
|
|
|
574
597
|
/**
|
|
@@ -610,9 +633,18 @@ export function getResearchStopMessage(piSessionId: string | undefined, research
|
|
|
610
633
|
// search engine blocking" here mis-sends the user to debug their network; the
|
|
611
634
|
// readiness probes do not even run a search, so a failure is not evidence of bot
|
|
612
635
|
// blocking.
|
|
636
|
+
// Lead with the RECORDED errors when we have them — the actual root cause
|
|
637
|
+
// (e.g. a provider 429) beats generic guidance every time.
|
|
638
|
+
const state = getPiState(sid);
|
|
639
|
+
const reasons = state.failureReasons.get(researchId);
|
|
640
|
+
const recorded = reasons && reasons.size > 0
|
|
641
|
+
? ['Recorded errors:', ...[...reasons].map(([id, r]) => `• researcher ${id}: ${r}`), '']
|
|
642
|
+
: [];
|
|
643
|
+
|
|
613
644
|
return [
|
|
614
645
|
`Research stopped: ${count} researcher(s) did not return a usable report: ${failed.join(', ')}.`,
|
|
615
646
|
'',
|
|
647
|
+
...recorded,
|
|
616
648
|
'Most likely cause — the research model could not produce grounded results:',
|
|
617
649
|
'• Try a more capable research model. Very small or "thinking-only" models often',
|
|
618
650
|
' emit no final text after their tool calls, which fails every researcher.',
|
package/src/sdk.ts
CHANGED
|
@@ -236,6 +236,19 @@ async function _doInit(options: ResearchSDKOptions = {}): Promise<void> {
|
|
|
236
236
|
globalApiKey = options.apiKey || process.env['PI_RESEARCH_API_KEY'];
|
|
237
237
|
let parsedProvider = options.provider || process.env['PI_RESEARCH_PROVIDER'];
|
|
238
238
|
|
|
239
|
+
// An EXPLICIT model option must govern the entire run — coordinator (via
|
|
240
|
+
// ctx.model) AND researchers/synthesis (which resolve through
|
|
241
|
+
// RESEARCH_MODEL, where config would otherwise outrank the option and split
|
|
242
|
+
// the run across two models, with the report metadata naming the wrong one).
|
|
243
|
+
// Pin the config to the explicit choice. No-op when the option was itself
|
|
244
|
+
// seeded from the config (the CLI does that).
|
|
245
|
+
if (options.model) {
|
|
246
|
+
const explicit = typeof options.model === 'string'
|
|
247
|
+
? options.model
|
|
248
|
+
: `${(options.model as Model<any>).provider}/${(options.model as Model<any>).id}`;
|
|
249
|
+
globalConfig = { ...globalConfig, RESEARCH_MODEL: explicit };
|
|
250
|
+
}
|
|
251
|
+
|
|
239
252
|
// Infer provider from the effective model if not explicitly provided. The
|
|
240
253
|
// effective model is the explicit option first, else the configured
|
|
241
254
|
// RESEARCH_MODEL — the same precedence the resolve below uses.
|