@lincoln504/pi-research 1.3.9 → 1.3.10
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 +56 -27
- package/dist/thread-worker.mjs +32 -9
- package/docs/ARCHITECTURE.md +8 -6
- package/docs/CHANGELOG.md +28 -0
- package/docs/CONFIGURATION.md +1 -0
- package/package.json +1 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/config.ts +12 -0
- package/src/constants.ts +8 -13
- package/src/infrastructure/browser/thread-worker.mjs +32 -9
- package/src/orchestration/quick-research-orchestrator.ts +6 -6
- package/src/orchestration/researcher-executor.ts +8 -6
- package/src/orchestration/researcher.ts +13 -2
- package/src/web-research/scraper-utils.ts +58 -12
- package/src/web-research/web-scraper.ts +13 -8
package/dist/cli.mjs
CHANGED
|
@@ -2662,6 +2662,7 @@ function saveConfig(config, scope = "local", cwd = process.cwd(), changedKeys) {
|
|
|
2662
2662
|
PI_RESEARCH_LLM_THINKING_LEVEL: config.LLM_THINKING_LEVEL,
|
|
2663
2663
|
PI_RESEARCH_PLANNING_MAX_TOKENS: String(config.PLANNING_MAX_TOKENS),
|
|
2664
2664
|
PI_RESEARCH_SYNTHESIS_MAX_TOKENS: String(config.SYNTHESIS_MAX_TOKENS),
|
|
2665
|
+
PI_RESEARCH_RESEARCHER_MAX_TOKENS: String(config.RESEARCHER_MAX_TOKENS),
|
|
2665
2666
|
PI_RESEARCH_MIGRATION_STRATEGY: config.MIGRATION_STRATEGY,
|
|
2666
2667
|
PI_RESEARCH_CONSOLE_LOG: String(config.CONSOLE_LOG),
|
|
2667
2668
|
...config.RESEARCH_MODEL ? { PI_RESEARCH_MODEL: config.RESEARCH_MODEL } : {},
|
|
@@ -2826,6 +2827,7 @@ function createConfig(env, processEnv) {
|
|
|
2826
2827
|
LLM_THINKING_LEVEL: parseEnvEnum(e, "PI_RESEARCH_LLM_THINKING_LEVEL", ["off", "minimal", "low", "medium", "high"], DEFAULTS.LLM_THINKING_LEVEL),
|
|
2827
2828
|
PLANNING_MAX_TOKENS: parseEnvNumber(e, "PI_RESEARCH_PLANNING_MAX_TOKENS", DEFAULTS.PLANNING_MAX_TOKENS, 1024, 131072),
|
|
2828
2829
|
SYNTHESIS_MAX_TOKENS: parseEnvNumber(e, "PI_RESEARCH_SYNTHESIS_MAX_TOKENS", DEFAULTS.SYNTHESIS_MAX_TOKENS, 1024, 131072),
|
|
2830
|
+
RESEARCHER_MAX_TOKENS: parseEnvNumber(e, "PI_RESEARCH_RESEARCHER_MAX_TOKENS", DEFAULTS.RESEARCHER_MAX_TOKENS, 1024, 131072),
|
|
2829
2831
|
MIGRATION_STRATEGY: parseEnvEnum(e, "PI_RESEARCH_MIGRATION_STRATEGY", ["drop", "re-embed", "backup"], DEFAULTS.MIGRATION_STRATEGY),
|
|
2830
2832
|
CONSOLE_LOG: parseEnvBool(e, "PI_RESEARCH_CONSOLE_LOG", DEFAULTS.CONSOLE_LOG),
|
|
2831
2833
|
RESEARCH_MODEL: parseEnvString(e, "PI_RESEARCH_MODEL", DEFAULTS.RESEARCH_MODEL),
|
|
@@ -3013,6 +3015,15 @@ var init_config = __esm({
|
|
|
3013
3015
|
* the model's real maxTokens at call time.
|
|
3014
3016
|
* Not exposed in TUI — controlled via PI_RESEARCH_SYNTHESIS_MAX_TOKENS env var. */
|
|
3015
3017
|
SYNTHESIS_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 32768 }),
|
|
3018
|
+
/** Max OUTPUT tokens per researcher LLM call (deep and quick sub-agent sessions).
|
|
3019
|
+
* Without a cap the session inherits the model's full catalog ceiling as the
|
|
3020
|
+
* per-request max_tokens — ~230k for large-context models — which providers that
|
|
3021
|
+
* budget-check max_tokens up front (OpenRouter 402s against remaining credits)
|
|
3022
|
+
* reject outright on low balances, even though the actual response would cost
|
|
3023
|
+
* cents. Default 16384: researcher turns are tool calls plus a cited report, far
|
|
3024
|
+
* below this. Clamped to the model's real ceiling at session build time.
|
|
3025
|
+
* Not exposed in TUI — controlled via PI_RESEARCH_RESEARCHER_MAX_TOKENS env var. */
|
|
3026
|
+
RESEARCHER_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 16384 }),
|
|
3016
3027
|
/** LLM Model override for researcher sub-agents and knowledge synthesis.
|
|
3017
3028
|
* Format: provider/model-id (e.g. google/gemini-2.0-flash-001) or just model-id.
|
|
3018
3029
|
* When set, this overrides ctx.model for researcher sub-agents (both deep and quick)
|
|
@@ -3090,6 +3101,7 @@ var init_config = __esm({
|
|
|
3090
3101
|
"PI_RESEARCH_LLM_THINKING_LEVEL",
|
|
3091
3102
|
"PI_RESEARCH_PLANNING_MAX_TOKENS",
|
|
3092
3103
|
"PI_RESEARCH_SYNTHESIS_MAX_TOKENS",
|
|
3104
|
+
"PI_RESEARCH_RESEARCHER_MAX_TOKENS",
|
|
3093
3105
|
"PI_RESEARCH_MIGRATION_STRATEGY",
|
|
3094
3106
|
"PI_RESEARCH_CONSOLE_LOG",
|
|
3095
3107
|
"PI_RESEARCH_MODEL",
|
|
@@ -3132,8 +3144,7 @@ function getMaxScrapeBatches(config) {
|
|
|
3132
3144
|
}
|
|
3133
3145
|
}
|
|
3134
3146
|
function resolveExcludedTools(callerExclusions, configDisabled = []) {
|
|
3135
|
-
|
|
3136
|
-
return [.../* @__PURE__ */ new Set([...base, ...configDisabled])];
|
|
3147
|
+
return [.../* @__PURE__ */ new Set([...DEFAULT_EXCLUDED_TOOLS, ...callerExclusions ?? [], ...configDisabled])];
|
|
3137
3148
|
}
|
|
3138
3149
|
var MAX_GATHERING_CALLS, MAX_GREP_CALLS, MAX_SCRAPE_URLS, MAX_TEAM_SIZE_LEVEL_1, MAX_TEAM_SIZE_LEVEL_2, MAX_TEAM_SIZE_LEVEL_3, MAX_ROUNDS_LEVEL_1, MAX_ROUNDS_LEVEL_2, MAX_ROUNDS_LEVEL_3, MAX_EXTRA_ROUNDS_WITH_STEERING, OSV_TIMEOUT_MS, WEBGPU_PROBE_TIMEOUT_MS, MAX_QUERY_LENGTH, MAX_FILENAME_QUERY_LENGTH, MIN_QUERY_LENGTH, MAX_EXPORT_RETRIES, DEFAULT_MAX_RETRIES, DEFAULT_INITIAL_DELAY_MS, DEFAULT_MAX_DELAY_MS, DEFAULT_MODEL_CONTEXT_WINDOW, BATCH_2_DEFAULT_CONCURRENCY, MAX_SCRAPE_CONTENT_CHARS_PER_DOC, RESEARCHER_LAUNCH_DELAY_MS, MAX_QUERIES_PER_RESEARCHER_LEVEL_1, MAX_QUERIES_PER_RESEARCHER_LEVEL_2, MAX_QUERIES_PER_RESEARCHER_LEVEL_3, DEFAULT_EXCLUDED_TOOLS;
|
|
3139
3150
|
var init_constants = __esm({
|
|
@@ -17024,17 +17035,30 @@ function ssrfSafeLookup(hostname, options, callback, resolveImpl = dnsCb.lookup)
|
|
|
17024
17035
|
return callback(null, safe[0].address, safe[0].family);
|
|
17025
17036
|
});
|
|
17026
17037
|
}
|
|
17027
|
-
|
|
17028
|
-
|
|
17029
|
-
|
|
17038
|
+
function isSsrfBlockError(error) {
|
|
17039
|
+
let e = error;
|
|
17040
|
+
for (let depth = 0; depth < 5 && e instanceof Error; depth++) {
|
|
17041
|
+
if (e.code === "ESSRFBLOCKED" || e.message.includes("not allowed")) {
|
|
17042
|
+
return true;
|
|
17043
|
+
}
|
|
17044
|
+
e = e.cause;
|
|
17045
|
+
}
|
|
17046
|
+
return false;
|
|
17047
|
+
}
|
|
17048
|
+
var cachedSsrfFetcher = void 0;
|
|
17049
|
+
async function getSsrfSafeFetcher() {
|
|
17050
|
+
if (cachedSsrfFetcher !== void 0) return cachedSsrfFetcher;
|
|
17030
17051
|
try {
|
|
17031
|
-
const { Agent: Agent2 } = await import("undici");
|
|
17032
|
-
|
|
17052
|
+
const { Agent: Agent2, fetch: undiciFetch } = await import("undici");
|
|
17053
|
+
cachedSsrfFetcher = {
|
|
17054
|
+
fetch: undiciFetch,
|
|
17055
|
+
dispatcher: new Agent2({ connect: { lookup: ssrfSafeLookup } })
|
|
17056
|
+
};
|
|
17033
17057
|
} catch (e) {
|
|
17034
17058
|
logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
|
|
17035
|
-
|
|
17059
|
+
cachedSsrfFetcher = null;
|
|
17036
17060
|
}
|
|
17037
|
-
return
|
|
17061
|
+
return cachedSsrfFetcher;
|
|
17038
17062
|
}
|
|
17039
17063
|
function formatErrorWithCause(err) {
|
|
17040
17064
|
try {
|
|
@@ -17284,7 +17308,7 @@ function decodeHtmlBody(bytes, contentTypeHeader) {
|
|
|
17284
17308
|
}
|
|
17285
17309
|
}
|
|
17286
17310
|
function trackFetchLayerFailure(url, error) {
|
|
17287
|
-
const isSsrf = error
|
|
17311
|
+
const isSsrf = isSsrfBlockError(error);
|
|
17288
17312
|
errorTracker.trackError(error instanceof Error ? error : String(error), {
|
|
17289
17313
|
component: "scrapers",
|
|
17290
17314
|
operation: "fetch",
|
|
@@ -17296,7 +17320,8 @@ function trackFetchLayerFailure(url, error) {
|
|
|
17296
17320
|
}
|
|
17297
17321
|
async function scrapeWithFetch(url, signal) {
|
|
17298
17322
|
await validateUrlForSSRF(url);
|
|
17299
|
-
const
|
|
17323
|
+
const fetcher = await getSsrfSafeFetcher();
|
|
17324
|
+
const doFetch = fetcher?.fetch ?? ((u, init) => fetch(u, init));
|
|
17300
17325
|
const controller = new AbortController();
|
|
17301
17326
|
const timeoutId = setTimeout(() => controller.abort(), FETCH_LAYER_TIMEOUT);
|
|
17302
17327
|
safeUnref(timeoutId);
|
|
@@ -17315,15 +17340,15 @@ async function scrapeWithFetch(url, signal) {
|
|
|
17315
17340
|
let response;
|
|
17316
17341
|
const userAgent = getRandomUserAgent();
|
|
17317
17342
|
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
17318
|
-
response = await
|
|
17343
|
+
response = await doFetch(currentUrl, {
|
|
17319
17344
|
signal: controller.signal,
|
|
17320
17345
|
redirect: "manual",
|
|
17321
17346
|
headers: {
|
|
17322
17347
|
"User-Agent": userAgent,
|
|
17323
17348
|
"Accept": "text/html,application/xhtml+xml,application/pdf,*/*;q=0.8"
|
|
17324
17349
|
},
|
|
17325
|
-
//
|
|
17326
|
-
...
|
|
17350
|
+
// Only ever attached alongside the fetch that honours it (see above).
|
|
17351
|
+
...fetcher ? { dispatcher: fetcher.dispatcher } : {}
|
|
17327
17352
|
});
|
|
17328
17353
|
if (response.status >= 300 && response.status < 400) {
|
|
17329
17354
|
const location = response.headers.get("location");
|
|
@@ -17424,7 +17449,7 @@ async function scrapeWithFetch(url, signal) {
|
|
|
17424
17449
|
metrics.observe("scrape_latency_ms", fetchDuration, { layer: "fetch", content_type: "html", status: "success" });
|
|
17425
17450
|
return { source: "fetch", layer: "fetch", markdown };
|
|
17426
17451
|
} catch (error) {
|
|
17427
|
-
if (error
|
|
17452
|
+
if (isSsrfBlockError(error)) {
|
|
17428
17453
|
metrics.increment("scrape_operations_total", 1, { layer: "fetch", status: "ssrf_blocked" });
|
|
17429
17454
|
metrics.observe("scrape_latency_ms", Date.now() - fetchStart, { layer: "fetch", status: "ssrf_blocked" });
|
|
17430
17455
|
throw error;
|
|
@@ -21524,6 +21549,8 @@ async function createResearcherSession(options) {
|
|
|
21524
21549
|
cwd
|
|
21525
21550
|
});
|
|
21526
21551
|
logger.info(`[Researcher] Using model for researcher ${researcherId}: ${modelToUse.provider}/${modelToUse.id}`);
|
|
21552
|
+
const outputCap = config?.RESEARCHER_MAX_TOKENS ?? 16384;
|
|
21553
|
+
const sessionModel = modelToUse.maxTokens > outputCap ? { ...modelToUse, maxTokens: outputCap } : modelToUse;
|
|
21527
21554
|
try {
|
|
21528
21555
|
const tracker = new ToolUsageTracker(createDefaultToolLimits(config));
|
|
21529
21556
|
const globalLinks = updateGlobalLinks || (() => {
|
|
@@ -21564,7 +21591,7 @@ async function createResearcherSession(options) {
|
|
|
21564
21591
|
sessionManager: SessionManager.inMemory(),
|
|
21565
21592
|
// Each researcher gets its own isolated session
|
|
21566
21593
|
settingsManager: researcherSettings,
|
|
21567
|
-
model:
|
|
21594
|
+
model: sessionModel,
|
|
21568
21595
|
// pi 0.80.8 replaced the SDK's `modelRegistry`/`authStorage` session options
|
|
21569
21596
|
// with the async `modelRuntime`. Pass the runtime backing OUR registry when
|
|
21570
21597
|
// this run built one (standalone CLI/SDK): it carries any explicit API key
|
|
@@ -21595,7 +21622,7 @@ async function createResearcherSession(options) {
|
|
|
21595
21622
|
throw new Error("Session creation returned invalid result");
|
|
21596
21623
|
}
|
|
21597
21624
|
sessionRef.session = result.session;
|
|
21598
|
-
return { session: result.session, resolvedModel:
|
|
21625
|
+
return { session: result.session, resolvedModel: sessionModel };
|
|
21599
21626
|
} catch (tailError) {
|
|
21600
21627
|
const session = result?.session;
|
|
21601
21628
|
if (session && typeof session.abort === "function") {
|
|
@@ -21618,7 +21645,7 @@ async function createResearcherSession(options) {
|
|
|
21618
21645
|
|
|
21619
21646
|
// src/orchestration/researcher-executor.ts
|
|
21620
21647
|
init_text_utils();
|
|
21621
|
-
|
|
21648
|
+
init_llm_usage();
|
|
21622
21649
|
init_logger();
|
|
21623
21650
|
init_metrics();
|
|
21624
21651
|
init_service_interfaces();
|
|
@@ -22054,12 +22081,13 @@ ${prompt}`);
|
|
|
22054
22081
|
if (msg2?.["role"] !== "assistant") return;
|
|
22055
22082
|
const rawUsage = msg2["usage"];
|
|
22056
22083
|
if (rawUsage) {
|
|
22057
|
-
const { tokens, cost } =
|
|
22084
|
+
const { tokens, cost } = recordLlmUsage(resolvedModel, rawUsage, {
|
|
22085
|
+
component: "researcher",
|
|
22086
|
+
complexity,
|
|
22087
|
+
observer: { onTokensConsumed: (t, c) => safeObserve(() => observer?.onTokensConsumed?.(t, c)) }
|
|
22088
|
+
});
|
|
22058
22089
|
if (tokens > 0 || cost > 0) {
|
|
22059
|
-
metrics.increment("llm_tokens_total", tokens, { component: "researcher", complexity: String(complexity) });
|
|
22060
|
-
metrics.increment("llm_cost_total", cost, { component: "researcher", complexity: String(complexity) });
|
|
22061
22090
|
safeObserve(() => observer?.onResearcherProgress?.(id, void 0, tokens, cost));
|
|
22062
|
-
safeObserve(() => observer?.onTokensConsumed?.(tokens, cost));
|
|
22063
22091
|
}
|
|
22064
22092
|
}
|
|
22065
22093
|
} else if (event.type === "tool_execution_start") {
|
|
@@ -22186,7 +22214,7 @@ ${responseText}`);
|
|
|
22186
22214
|
|
|
22187
22215
|
// src/orchestration/quick-research-orchestrator.ts
|
|
22188
22216
|
init_prompts();
|
|
22189
|
-
|
|
22217
|
+
init_llm_usage();
|
|
22190
22218
|
init_logger();
|
|
22191
22219
|
init_config();
|
|
22192
22220
|
init_text_utils();
|
|
@@ -22467,12 +22495,13 @@ ${prompt}`);
|
|
|
22467
22495
|
if (msg2?.["role"] !== "assistant") return;
|
|
22468
22496
|
const rawUsage = msg2["usage"];
|
|
22469
22497
|
if (rawUsage) {
|
|
22470
|
-
const { tokens, cost } =
|
|
22498
|
+
const { tokens, cost } = recordLlmUsage(resolvedModel, rawUsage, {
|
|
22499
|
+
component: "quick_researcher",
|
|
22500
|
+
complexity: 0,
|
|
22501
|
+
observer
|
|
22502
|
+
});
|
|
22471
22503
|
if (tokens > 0 || cost > 0) {
|
|
22472
|
-
metrics.increment("llm_tokens_total", tokens, { component: "quick_researcher", complexity: "0" });
|
|
22473
|
-
metrics.increment("llm_cost_total", cost, { component: "quick_researcher", complexity: "0" });
|
|
22474
22504
|
observer?.onResearcherProgress?.("quick", void 0, tokens, cost);
|
|
22475
|
-
observer?.onTokensConsumed?.(tokens, cost);
|
|
22476
22505
|
}
|
|
22477
22506
|
}
|
|
22478
22507
|
} else if (event.type === "tool_execution_start") {
|
package/dist/thread-worker.mjs
CHANGED
|
@@ -1512,8 +1512,9 @@ __export(scraper_utils_exports, {
|
|
|
1512
1512
|
extractDomain: () => extractDomain,
|
|
1513
1513
|
formatErrorWithCause: () => formatErrorWithCause,
|
|
1514
1514
|
getRandomUserAgent: () => getRandomUserAgent,
|
|
1515
|
-
|
|
1515
|
+
getSsrfSafeFetcher: () => getSsrfSafeFetcher,
|
|
1516
1516
|
isBenignScrapeFailure: () => isBenignScrapeFailure,
|
|
1517
|
+
isSsrfBlockError: () => isSsrfBlockError,
|
|
1517
1518
|
ssrfSafeLookup: () => ssrfSafeLookup,
|
|
1518
1519
|
stripImageLinks: () => stripImageLinks,
|
|
1519
1520
|
validateContent: () => validateContent,
|
|
@@ -1633,16 +1634,29 @@ function ssrfSafeLookup(hostname, options, callback, resolveImpl = dnsCb.lookup)
|
|
|
1633
1634
|
return callback(null, safe[0].address, safe[0].family);
|
|
1634
1635
|
});
|
|
1635
1636
|
}
|
|
1636
|
-
|
|
1637
|
-
|
|
1637
|
+
function isSsrfBlockError(error) {
|
|
1638
|
+
let e = error;
|
|
1639
|
+
for (let depth = 0; depth < 5 && e instanceof Error; depth++) {
|
|
1640
|
+
if (e.code === "ESSRFBLOCKED" || e.message.includes("not allowed")) {
|
|
1641
|
+
return true;
|
|
1642
|
+
}
|
|
1643
|
+
e = e.cause;
|
|
1644
|
+
}
|
|
1645
|
+
return false;
|
|
1646
|
+
}
|
|
1647
|
+
async function getSsrfSafeFetcher() {
|
|
1648
|
+
if (cachedSsrfFetcher !== void 0) return cachedSsrfFetcher;
|
|
1638
1649
|
try {
|
|
1639
|
-
const { Agent } = await import("undici");
|
|
1640
|
-
|
|
1650
|
+
const { Agent, fetch: undiciFetch } = await import("undici");
|
|
1651
|
+
cachedSsrfFetcher = {
|
|
1652
|
+
fetch: undiciFetch,
|
|
1653
|
+
dispatcher: new Agent({ connect: { lookup: ssrfSafeLookup } })
|
|
1654
|
+
};
|
|
1641
1655
|
} catch (e) {
|
|
1642
1656
|
logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
|
|
1643
|
-
|
|
1657
|
+
cachedSsrfFetcher = null;
|
|
1644
1658
|
}
|
|
1645
|
-
return
|
|
1659
|
+
return cachedSsrfFetcher;
|
|
1646
1660
|
}
|
|
1647
1661
|
function formatErrorWithCause(err) {
|
|
1648
1662
|
try {
|
|
@@ -1776,7 +1790,7 @@ function validateContent(html, markdown, url) {
|
|
|
1776
1790
|
throw error;
|
|
1777
1791
|
}
|
|
1778
1792
|
}
|
|
1779
|
-
var
|
|
1793
|
+
var cachedSsrfFetcher;
|
|
1780
1794
|
var init_scraper_utils = __esm({
|
|
1781
1795
|
"src/web-research/scraper-utils.ts"() {
|
|
1782
1796
|
"use strict";
|
|
@@ -1785,7 +1799,7 @@ var init_scraper_utils = __esm({
|
|
|
1785
1799
|
init_logger();
|
|
1786
1800
|
init_scraper_types();
|
|
1787
1801
|
init_browser_error_utils();
|
|
1788
|
-
|
|
1802
|
+
cachedSsrfFetcher = void 0;
|
|
1789
1803
|
}
|
|
1790
1804
|
});
|
|
1791
1805
|
|
|
@@ -2530,6 +2544,15 @@ var ConfigSchema = Type.Object({
|
|
|
2530
2544
|
* the model's real maxTokens at call time.
|
|
2531
2545
|
* Not exposed in TUI — controlled via PI_RESEARCH_SYNTHESIS_MAX_TOKENS env var. */
|
|
2532
2546
|
SYNTHESIS_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 32768 }),
|
|
2547
|
+
/** Max OUTPUT tokens per researcher LLM call (deep and quick sub-agent sessions).
|
|
2548
|
+
* Without a cap the session inherits the model's full catalog ceiling as the
|
|
2549
|
+
* per-request max_tokens — ~230k for large-context models — which providers that
|
|
2550
|
+
* budget-check max_tokens up front (OpenRouter 402s against remaining credits)
|
|
2551
|
+
* reject outright on low balances, even though the actual response would cost
|
|
2552
|
+
* cents. Default 16384: researcher turns are tool calls plus a cited report, far
|
|
2553
|
+
* below this. Clamped to the model's real ceiling at session build time.
|
|
2554
|
+
* Not exposed in TUI — controlled via PI_RESEARCH_RESEARCHER_MAX_TOKENS env var. */
|
|
2555
|
+
RESEARCHER_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 16384 }),
|
|
2533
2556
|
/** LLM Model override for researcher sub-agents and knowledge synthesis.
|
|
2534
2557
|
* Format: provider/model-id (e.g. google/gemini-2.0-flash-001) or just model-id.
|
|
2535
2558
|
* When set, this overrides ctx.model for researcher sub-agents (both deep and quick)
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -115,12 +115,14 @@ agent to invoke:
|
|
|
115
115
|
In deep research `search` is excluded — the coordinator runs the search burst and hands
|
|
116
116
|
out URLs directly.
|
|
117
117
|
|
|
118
|
-
`grep` is excluded
|
|
119
|
-
|
|
120
|
-
searching the local filesystem.
|
|
121
|
-
on the CLI, the `excludeTools` tool parameter in the extension
|
|
122
|
-
|
|
123
|
-
|
|
118
|
+
`grep` is excluded at **every** depth and on every front-end (CLI, SDK, agent skill,
|
|
119
|
+
pi extension): this is web research, and a capable model otherwise spends turns
|
|
120
|
+
searching the local filesystem. Both exclusion surfaces — the `excludeTools` list
|
|
121
|
+
(`--exclude-tools` on the CLI, the `excludeTools` tool parameter in the extension) and
|
|
122
|
+
`PI_RESEARCH_DISABLED_TOOLS` — are strictly additive on top of that default: they can
|
|
123
|
+
only ever remove capabilities (see [CONFIGURATION.md](CONFIGURATION.md)). Before 1.3.10
|
|
124
|
+
a non-empty `excludeTools` list replaced the default, so naming any other tool silently
|
|
125
|
+
re-enabled `grep`.
|
|
124
126
|
|
|
125
127
|
Researchers cannot write files, run shell commands, or reach the network outside these
|
|
126
128
|
tools.
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.3.10] - 2026-08-14
|
|
9
|
+
|
|
10
|
+
The post-release verification round for 1.3.9 kept digging and found that the
|
|
11
|
+
fetch scrape path — the fast primary transport in front of the stealth browser —
|
|
12
|
+
had been dead in production on every supported Node version, hidden completely by
|
|
13
|
+
the browser fallback. Three more researcher-session defects surfaced from the same
|
|
14
|
+
investigation, two of them demonstrated live against a real provider. Each fix
|
|
15
|
+
carries a regression test verified to fail against the pre-fix code (the
|
|
16
|
+
accounting fix pins the deep-researcher site; the quick site shares the same
|
|
17
|
+
helper call), and the fetch fix carries the first test of that layer that uses a
|
|
18
|
+
real socket instead of a mocked `fetch`. An independent fresh-eyes review of the
|
|
19
|
+
whole delta then caught two follow-on defects, fixed below.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- **Every fetch scrape failed instantly; the whole path silently ran on the browser fallback.** `getSsrfSafeDispatcher()` built an `Agent` from the undici 8.x this package depends on and handed it to Node's **global** `fetch()` — which is backed by the undici compiled *into* the runtime (6.x on Node 22, 7.x on Node 25). Cross-major dispatchers are rejected outright (`UND_ERR_INVALID_ARG: invalid onRequestStart method`), so every fetch attempt threw before a single packet left the machine, burned one retry, and fell back to the stealth browser: ~3.4 s per page against the ~0.9 s the fetch path delivers, the browser pool saturated with work it never needed — and the connect-time SSRF DNS pin, the entire reason the dispatcher exists, never engaged once (request-time validation still applied throughout, so this was defence-in-depth loss, not an open hole). Nothing caught it because every test of the layer stubs the global `fetch`, and the fallback kept scrapes succeeding. The replacement, `getSsrfSafeFetcher()`, returns the Agent *paired with undici's own `fetch`*, making the invalid combination unrepresentable, and a new test drives the real fetch path against a real loopback socket with the browser layer disabled — it fails against the pre-fix code with the exact production error. Measured on live runs: 0 fetch / 5 browser successes before, 4 fetch / 0 browser after.
|
|
24
|
+
- **Researchers sent the model's full ~230k catalog ceiling as `max_tokens` — and OpenRouter 402'd every call on a low balance.** The researcher session inherited the model's catalog `maxTokens` as its per-request output cap. Providers that budget-check `max_tokens` up front rejected the call before running it: observed live as `402: You requested up to 231037 tokens, but can only afford 24467` on every researcher, failing entire runs whose real cost was cents. Researcher output per call — tool calls plus a cited report — is nowhere near that. New `PI_RESEARCH_RESEARCHER_MAX_TOKENS` knob (default 16384, range 1024–131072), clamped to the model's real ceiling, applied in the shared session factory so deep and quick sub-agents are both covered.
|
|
25
|
+
- **`--exclude-tools` silently granted researchers local-filesystem `grep`.** A non-empty exclusion list *replaced* the default exclusions instead of adding to them, so `--exclude-tools stackexchange` enabled a capability as a side effect of a flag documented as "disable internal tools". The same defect was fixed for `PI_RESEARCH_DISABLED_TOOLS` in 1.3.0 with the rationale "a subtract-only setting must never grant a capability"; the caller-facing surface kept the replacement semantics until now. Both surfaces are strictly additive over the default exclusions. (Behavioral change: there is no longer any way to hand researchers `grep`, which ARCHITECTURE.md previously documented as a quirk of the replacement rule.)
|
|
26
|
+
- **A misconfigured $0 price table was silent on depth-0 runs.** Both researcher `message_end` accounting sites hand-copied the extract→increment→emit block instead of calling `recordLlmUsage`, so `warnIfUnpriced` — added in 1.1.2 precisely because a hand-written `models.json` entry silently discards pi's pricing — never ran for researcher usage. Depth-1+ runs still warned via the coordinator; depth-0 runs have no coordinator and reported `$0.0000` with no diagnostic at all. Metric labels and observer payloads are unchanged (`onTokensConsumed` now fires before `onResearcherProgress` rather than after — no consumer is order-sensitive), and the deep path keeps its `safeObserve` isolation.
|
|
27
|
+
- **A connect-time SSRF block was filed as a generic fetch error.** undici never surfaces the connector's `ESSRFBLOCKED` error directly — `fetch` throws `TypeError: fetch failed` with the real error down the `cause` chain — so the message-based classification at both tracking sites missed it and a DNS-rebinding block would have been invisible in the SSRF metrics. Unreachable before this release (the dispatcher path had never executed); now that it is live, `isSsrfBlockError()` walks the bounded cause chain. Diagnostics-only: the block itself always held.
|
|
28
|
+
- **One unit test silently made live network requests.** The fetch-stub migration for the undici change covered two of the three test files that drive the scrape layer; the missed one (`scraper-pdf-native-module-failure`) kept stubbing the *global* fetch, so every run of that "hermetic" test performed real DNS and an outbound TLS connection to a third-party host — and passed only because that host's certificate mismatch happened to reproduce the stubbed failure. Found by the fresh-eyes review; it now shims `getSsrfSafeFetcher` like its siblings.
|
|
29
|
+
|
|
30
|
+
### Verified
|
|
31
|
+
|
|
32
|
+
- Full suites green after the changes: 191 unit files / 2457 tests, integration 53+8 skipped and 103, lint, both type-checks, dependency rules.
|
|
33
|
+
- Live production runs on OpenRouter at depths 0, 1, and 2: reports with citations, fetch-path scrapes at ~0.6–1.5 s/page with zero browser fallbacks and zero undici errors, non-zero cost accounting, knowledge store round-trip (same-day scrapes retrieved and grounded).
|
|
34
|
+
- The stderr FD-2 capture deliberately still skips pipes/sockets: when a parent process is reading stderr (pi host, MCP, CI), stealing FD 2 would hide fatal diagnostics to suppress cosmetic Dawn noise. Reviewed and kept as designed.
|
|
35
|
+
|
|
8
36
|
## [1.3.9] - 2026-08-14
|
|
9
37
|
|
|
10
38
|
A whole-tree audit sweep. Six defects, each found by looking at what the code
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -114,6 +114,7 @@ These are advanced, env-only knobs (not in the TUI).
|
|
|
114
114
|
| `PI_RESEARCH_LLM_THINKING_LEVEL` | `off` | off · minimal · low · medium · high | Chain-of-thought level for all engine LLM work (coordinator, evaluator, synthesis, JSON-repair, knowledge extraction, and researcher sub-agents). Off by default — these calls emit structured JSON / cited reports, so a thinking block only consumes the output budget and can truncate the answer. Clamped per model by pi. |
|
|
115
115
|
| `PI_RESEARCH_PLANNING_MAX_TOKENS` | `16384` | 1024–131072 | Max output tokens for the plan + mid-round evaluator decision. Clamped to the model's real ceiling. |
|
|
116
116
|
| `PI_RESEARCH_SYNTHESIS_MAX_TOKENS` | `32768` | 1024–131072 | Max output tokens for the final synthesized report. Clamped to the model's real ceiling. |
|
|
117
|
+
| `PI_RESEARCH_RESEARCHER_MAX_TOKENS` | `16384` | 1024–131072 | Max output tokens per researcher LLM call (deep and quick). Without it the model's full catalog ceiling is sent as `max_tokens`, which OpenRouter budget-checks against remaining credits and rejects on low balances. |
|
|
117
118
|
|
|
118
119
|
Knowledge store
|
|
119
120
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lincoln504/pi-research",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.10",
|
|
4
4
|
"description": "Free unlimited web search and deep research for the Pi coding agent. Local knowledge store. Also works from Claude Code, Codex, and OpenClaw.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -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.3.
|
|
5
|
+
metadata: { "author": "Lincoln Deen", "version": "1.3.10", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
|
|
6
6
|
allowed-tools: Bash(node:*)
|
|
7
7
|
---
|
|
8
8
|
|
package/src/config.ts
CHANGED
|
@@ -122,6 +122,15 @@ export const ConfigSchema = Type.Object({
|
|
|
122
122
|
* the model's real maxTokens at call time.
|
|
123
123
|
* Not exposed in TUI — controlled via PI_RESEARCH_SYNTHESIS_MAX_TOKENS env var. */
|
|
124
124
|
SYNTHESIS_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 32768 }),
|
|
125
|
+
/** Max OUTPUT tokens per researcher LLM call (deep and quick sub-agent sessions).
|
|
126
|
+
* Without a cap the session inherits the model's full catalog ceiling as the
|
|
127
|
+
* per-request max_tokens — ~230k for large-context models — which providers that
|
|
128
|
+
* budget-check max_tokens up front (OpenRouter 402s against remaining credits)
|
|
129
|
+
* reject outright on low balances, even though the actual response would cost
|
|
130
|
+
* cents. Default 16384: researcher turns are tool calls plus a cited report, far
|
|
131
|
+
* below this. Clamped to the model's real ceiling at session build time.
|
|
132
|
+
* Not exposed in TUI — controlled via PI_RESEARCH_RESEARCHER_MAX_TOKENS env var. */
|
|
133
|
+
RESEARCHER_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 16384 }),
|
|
125
134
|
/** LLM Model override for researcher sub-agents and knowledge synthesis.
|
|
126
135
|
* Format: provider/model-id (e.g. google/gemini-2.0-flash-001) or just model-id.
|
|
127
136
|
* When set, this overrides ctx.model for researcher sub-agents (both deep and quick)
|
|
@@ -233,6 +242,7 @@ const USER_MIGRATION_KEYS = [
|
|
|
233
242
|
'PI_RESEARCH_LLM_THINKING_LEVEL',
|
|
234
243
|
'PI_RESEARCH_PLANNING_MAX_TOKENS',
|
|
235
244
|
'PI_RESEARCH_SYNTHESIS_MAX_TOKENS',
|
|
245
|
+
'PI_RESEARCH_RESEARCHER_MAX_TOKENS',
|
|
236
246
|
'PI_RESEARCH_MIGRATION_STRATEGY',
|
|
237
247
|
'PI_RESEARCH_CONSOLE_LOG',
|
|
238
248
|
'PI_RESEARCH_MODEL',
|
|
@@ -865,6 +875,7 @@ export function saveConfig(config: Config, scope: 'local' | 'user' = 'local', cw
|
|
|
865
875
|
PI_RESEARCH_LLM_THINKING_LEVEL: config.LLM_THINKING_LEVEL,
|
|
866
876
|
PI_RESEARCH_PLANNING_MAX_TOKENS: String(config.PLANNING_MAX_TOKENS),
|
|
867
877
|
PI_RESEARCH_SYNTHESIS_MAX_TOKENS: String(config.SYNTHESIS_MAX_TOKENS),
|
|
878
|
+
PI_RESEARCH_RESEARCHER_MAX_TOKENS: String(config.RESEARCHER_MAX_TOKENS),
|
|
868
879
|
PI_RESEARCH_MIGRATION_STRATEGY: config.MIGRATION_STRATEGY,
|
|
869
880
|
PI_RESEARCH_CONSOLE_LOG: String(config.CONSOLE_LOG),
|
|
870
881
|
...(config.RESEARCH_MODEL ? { PI_RESEARCH_MODEL: config.RESEARCH_MODEL } : {}),
|
|
@@ -1095,6 +1106,7 @@ export function createConfig(env: Record<string, string | undefined>, processEnv
|
|
|
1095
1106
|
LLM_THINKING_LEVEL: parseEnvEnum(e, 'PI_RESEARCH_LLM_THINKING_LEVEL', ['off', 'minimal', 'low', 'medium', 'high'] as const, DEFAULTS.LLM_THINKING_LEVEL),
|
|
1096
1107
|
PLANNING_MAX_TOKENS: parseEnvNumber(e, 'PI_RESEARCH_PLANNING_MAX_TOKENS', DEFAULTS.PLANNING_MAX_TOKENS, 1024, 131072),
|
|
1097
1108
|
SYNTHESIS_MAX_TOKENS: parseEnvNumber(e, 'PI_RESEARCH_SYNTHESIS_MAX_TOKENS', DEFAULTS.SYNTHESIS_MAX_TOKENS, 1024, 131072),
|
|
1109
|
+
RESEARCHER_MAX_TOKENS: parseEnvNumber(e, 'PI_RESEARCH_RESEARCHER_MAX_TOKENS', DEFAULTS.RESEARCHER_MAX_TOKENS, 1024, 131072),
|
|
1098
1110
|
MIGRATION_STRATEGY: parseEnvEnum(e, 'PI_RESEARCH_MIGRATION_STRATEGY', ['drop', 're-embed', 'backup'] as const, DEFAULTS.MIGRATION_STRATEGY),
|
|
1099
1111
|
CONSOLE_LOG: parseEnvBool(e, 'PI_RESEARCH_CONSOLE_LOG', DEFAULTS.CONSOLE_LOG),
|
|
1100
1112
|
RESEARCH_MODEL: parseEnvString(e, 'PI_RESEARCH_MODEL', DEFAULTS.RESEARCH_MODEL),
|
package/src/constants.ts
CHANGED
|
@@ -185,22 +185,17 @@ export const DEFAULT_EXCLUDED_TOOLS: readonly string[] = ['grep'];
|
|
|
185
185
|
/**
|
|
186
186
|
* Resolve the effective researcher exclusion list.
|
|
187
187
|
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* (`
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
* as a replacement meant disabling any unrelated tool silently ENABLED grep, so a
|
|
196
|
-
* subtract-only setting could grant a capability.
|
|
188
|
+
* EVERY input is strictly ADDITIVE on top of DEFAULT_EXCLUDED_TOOLS. Both
|
|
189
|
+
* subtract-only surfaces — `--exclude-tools` / `excludeTools` and
|
|
190
|
+
* PI_RESEARCH_DISABLED_TOOLS (`configDisabled`) — are documented as "disable
|
|
191
|
+
* tools", so neither may GRANT a capability as a side effect. Each has held the
|
|
192
|
+
* replacement semantics at some point, with the same consequence both times:
|
|
193
|
+
* excluding any unrelated tool silently enabled local-filesystem grep for web
|
|
194
|
+
* researchers.
|
|
197
195
|
*/
|
|
198
196
|
export function resolveExcludedTools(
|
|
199
197
|
callerExclusions: readonly string[] | undefined,
|
|
200
198
|
configDisabled: readonly string[] = [],
|
|
201
199
|
): string[] {
|
|
202
|
-
|
|
203
|
-
? callerExclusions
|
|
204
|
-
: DEFAULT_EXCLUDED_TOOLS;
|
|
205
|
-
return [...new Set([...base, ...configDisabled])];
|
|
200
|
+
return [...new Set([...DEFAULT_EXCLUDED_TOOLS, ...(callerExclusions ?? []), ...configDisabled])];
|
|
206
201
|
}
|
|
@@ -1512,8 +1512,9 @@ __export(scraper_utils_exports, {
|
|
|
1512
1512
|
extractDomain: () => extractDomain,
|
|
1513
1513
|
formatErrorWithCause: () => formatErrorWithCause,
|
|
1514
1514
|
getRandomUserAgent: () => getRandomUserAgent,
|
|
1515
|
-
|
|
1515
|
+
getSsrfSafeFetcher: () => getSsrfSafeFetcher,
|
|
1516
1516
|
isBenignScrapeFailure: () => isBenignScrapeFailure,
|
|
1517
|
+
isSsrfBlockError: () => isSsrfBlockError,
|
|
1517
1518
|
ssrfSafeLookup: () => ssrfSafeLookup,
|
|
1518
1519
|
stripImageLinks: () => stripImageLinks,
|
|
1519
1520
|
validateContent: () => validateContent,
|
|
@@ -1633,16 +1634,29 @@ function ssrfSafeLookup(hostname, options, callback, resolveImpl = dnsCb.lookup)
|
|
|
1633
1634
|
return callback(null, safe[0].address, safe[0].family);
|
|
1634
1635
|
});
|
|
1635
1636
|
}
|
|
1636
|
-
|
|
1637
|
-
|
|
1637
|
+
function isSsrfBlockError(error) {
|
|
1638
|
+
let e = error;
|
|
1639
|
+
for (let depth = 0; depth < 5 && e instanceof Error; depth++) {
|
|
1640
|
+
if (e.code === "ESSRFBLOCKED" || e.message.includes("not allowed")) {
|
|
1641
|
+
return true;
|
|
1642
|
+
}
|
|
1643
|
+
e = e.cause;
|
|
1644
|
+
}
|
|
1645
|
+
return false;
|
|
1646
|
+
}
|
|
1647
|
+
async function getSsrfSafeFetcher() {
|
|
1648
|
+
if (cachedSsrfFetcher !== void 0) return cachedSsrfFetcher;
|
|
1638
1649
|
try {
|
|
1639
|
-
const { Agent } = await import("undici");
|
|
1640
|
-
|
|
1650
|
+
const { Agent, fetch: undiciFetch } = await import("undici");
|
|
1651
|
+
cachedSsrfFetcher = {
|
|
1652
|
+
fetch: undiciFetch,
|
|
1653
|
+
dispatcher: new Agent({ connect: { lookup: ssrfSafeLookup } })
|
|
1654
|
+
};
|
|
1641
1655
|
} catch (e) {
|
|
1642
1656
|
logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
|
|
1643
|
-
|
|
1657
|
+
cachedSsrfFetcher = null;
|
|
1644
1658
|
}
|
|
1645
|
-
return
|
|
1659
|
+
return cachedSsrfFetcher;
|
|
1646
1660
|
}
|
|
1647
1661
|
function formatErrorWithCause(err) {
|
|
1648
1662
|
try {
|
|
@@ -1776,7 +1790,7 @@ function validateContent(html, markdown, url) {
|
|
|
1776
1790
|
throw error;
|
|
1777
1791
|
}
|
|
1778
1792
|
}
|
|
1779
|
-
var
|
|
1793
|
+
var cachedSsrfFetcher;
|
|
1780
1794
|
var init_scraper_utils = __esm({
|
|
1781
1795
|
"src/web-research/scraper-utils.ts"() {
|
|
1782
1796
|
"use strict";
|
|
@@ -1785,7 +1799,7 @@ var init_scraper_utils = __esm({
|
|
|
1785
1799
|
init_logger();
|
|
1786
1800
|
init_scraper_types();
|
|
1787
1801
|
init_browser_error_utils();
|
|
1788
|
-
|
|
1802
|
+
cachedSsrfFetcher = void 0;
|
|
1789
1803
|
}
|
|
1790
1804
|
});
|
|
1791
1805
|
|
|
@@ -2530,6 +2544,15 @@ var ConfigSchema = Type.Object({
|
|
|
2530
2544
|
* the model's real maxTokens at call time.
|
|
2531
2545
|
* Not exposed in TUI — controlled via PI_RESEARCH_SYNTHESIS_MAX_TOKENS env var. */
|
|
2532
2546
|
SYNTHESIS_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 32768 }),
|
|
2547
|
+
/** Max OUTPUT tokens per researcher LLM call (deep and quick sub-agent sessions).
|
|
2548
|
+
* Without a cap the session inherits the model's full catalog ceiling as the
|
|
2549
|
+
* per-request max_tokens — ~230k for large-context models — which providers that
|
|
2550
|
+
* budget-check max_tokens up front (OpenRouter 402s against remaining credits)
|
|
2551
|
+
* reject outright on low balances, even though the actual response would cost
|
|
2552
|
+
* cents. Default 16384: researcher turns are tool calls plus a cited report, far
|
|
2553
|
+
* below this. Clamped to the model's real ceiling at session build time.
|
|
2554
|
+
* Not exposed in TUI — controlled via PI_RESEARCH_RESEARCHER_MAX_TOKENS env var. */
|
|
2555
|
+
RESEARCHER_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 16384 }),
|
|
2533
2556
|
/** LLM Model override for researcher sub-agents and knowledge synthesis.
|
|
2534
2557
|
* Format: provider/model-id (e.g. google/gemini-2.0-flash-001) or just model-id.
|
|
2535
2558
|
* When set, this overrides ctx.model for researcher sub-agents (both deep and quick)
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
import { type Model } from '@earendil-works/pi-ai';
|
|
14
14
|
import { injectCurrentDate } from '../core/llm/inject-date.ts';
|
|
15
15
|
import { loadPrompt } from '../core/llm/prompts.ts';
|
|
16
|
-
import {
|
|
16
|
+
import { recordLlmUsage } from '../utils/llm-usage.ts';
|
|
17
17
|
import { logger } from '../logger.ts';
|
|
18
18
|
import { getConfig, type Config } from '../config.ts';
|
|
19
19
|
import { createResearcherSession } from './researcher.ts';
|
|
@@ -238,13 +238,13 @@ export class QuickResearchOrchestrator {
|
|
|
238
238
|
|
|
239
239
|
const rawUsage = msg['usage'] as any;
|
|
240
240
|
if (rawUsage) {
|
|
241
|
-
const { tokens, cost } =
|
|
242
|
-
|
|
241
|
+
const { tokens, cost } = recordLlmUsage(resolvedModel, rawUsage, {
|
|
242
|
+
component: 'quick_researcher',
|
|
243
|
+
complexity: 0,
|
|
244
|
+
observer,
|
|
245
|
+
});
|
|
243
246
|
if (tokens > 0 || cost > 0) {
|
|
244
|
-
metrics.increment('llm_tokens_total', tokens, { component: 'quick_researcher', complexity: '0' });
|
|
245
|
-
metrics.increment('llm_cost_total', cost, { component: 'quick_researcher', complexity: '0' });
|
|
246
247
|
observer?.onResearcherProgress?.('quick', undefined, tokens, cost);
|
|
247
|
-
observer?.onTokensConsumed?.(tokens, cost);
|
|
248
248
|
}
|
|
249
249
|
}
|
|
250
250
|
} else if (event.type === 'tool_execution_start') {
|
|
@@ -9,7 +9,7 @@ import type { SystemResearchState } from './deep-research-types.ts';
|
|
|
9
9
|
import { createResearcherSession } from './researcher.ts';
|
|
10
10
|
import { registerScrapedLinks } from '../utils/shared-links.ts';
|
|
11
11
|
import { ensureAssistantResponse } from '../utils/text-utils.ts';
|
|
12
|
-
import {
|
|
12
|
+
import { recordLlmUsage } from '../utils/llm-usage.ts';
|
|
13
13
|
import { logger } from '../logger.ts';
|
|
14
14
|
import { metrics } from '../utils/metrics.ts';
|
|
15
15
|
import { ServiceNames,
|
|
@@ -347,13 +347,15 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
|
|
|
347
347
|
|
|
348
348
|
const rawUsage = msg['usage'] as any;
|
|
349
349
|
if (rawUsage) {
|
|
350
|
-
|
|
351
|
-
|
|
350
|
+
// Observer exceptions must not kill the researcher, so hand recordLlmUsage
|
|
351
|
+
// a safeObserve-wrapped sink rather than the raw observer.
|
|
352
|
+
const { tokens, cost } = recordLlmUsage(resolvedModel, rawUsage, {
|
|
353
|
+
component: 'researcher',
|
|
354
|
+
complexity,
|
|
355
|
+
observer: { onTokensConsumed: (t, c) => safeObserve(() => observer?.onTokensConsumed?.(t, c)) },
|
|
356
|
+
});
|
|
352
357
|
if (tokens > 0 || cost > 0) {
|
|
353
|
-
metrics.increment('llm_tokens_total', tokens, { component: 'researcher', complexity: String(complexity) });
|
|
354
|
-
metrics.increment('llm_cost_total', cost, { component: 'researcher', complexity: String(complexity) });
|
|
355
358
|
safeObserve(() => observer?.onResearcherProgress?.(id, undefined, tokens, cost));
|
|
356
|
-
safeObserve(() => observer?.onTokensConsumed?.(tokens, cost));
|
|
357
359
|
}
|
|
358
360
|
}
|
|
359
361
|
} else if (event.type === 'tool_execution_start') {
|
|
@@ -85,6 +85,17 @@ export async function createResearcherSession(options: CreateResearcherSessionOp
|
|
|
85
85
|
|
|
86
86
|
logger.info(`[Researcher] Using model for researcher ${researcherId}: ${modelToUse.provider}/${modelToUse.id}`);
|
|
87
87
|
|
|
88
|
+
// The session sends model.maxTokens as the per-request max_tokens. Large-context
|
|
89
|
+
// catalog entries carry ~230k ceilings, and providers that budget-check max_tokens
|
|
90
|
+
// up front (OpenRouter 402s it against remaining credits) then reject every
|
|
91
|
+
// researcher call on a low balance even though the real response costs cents.
|
|
92
|
+
// Clamp the session's copy of the model to RESEARCHER_MAX_TOKENS; Model is plain
|
|
93
|
+
// catalog data, so a spread clone is safe and the shared registry entry is untouched.
|
|
94
|
+
const outputCap = config?.RESEARCHER_MAX_TOKENS ?? 16384;
|
|
95
|
+
const sessionModel = modelToUse.maxTokens > outputCap
|
|
96
|
+
? { ...modelToUse, maxTokens: outputCap }
|
|
97
|
+
: modelToUse;
|
|
98
|
+
|
|
88
99
|
try {
|
|
89
100
|
const tracker = new ToolUsageTracker(createDefaultToolLimits(config));
|
|
90
101
|
|
|
@@ -138,7 +149,7 @@ export async function createResearcherSession(options: CreateResearcherSessionOp
|
|
|
138
149
|
tools, // Explicit allowlist (BUG-1 fix)
|
|
139
150
|
sessionManager: SessionManager.inMemory(), // Each researcher gets its own isolated session
|
|
140
151
|
settingsManager: researcherSettings,
|
|
141
|
-
model:
|
|
152
|
+
model: sessionModel,
|
|
142
153
|
// pi 0.80.8 replaced the SDK's `modelRegistry`/`authStorage` session options
|
|
143
154
|
// with the async `modelRuntime`. Pass the runtime backing OUR registry when
|
|
144
155
|
// this run built one (standalone CLI/SDK): it carries any explicit API key
|
|
@@ -185,7 +196,7 @@ export async function createResearcherSession(options: CreateResearcherSessionOp
|
|
|
185
196
|
|
|
186
197
|
sessionRef.session = result.session;
|
|
187
198
|
|
|
188
|
-
return { session: result.session, resolvedModel:
|
|
199
|
+
return { session: result.session, resolvedModel: sessionModel };
|
|
189
200
|
} catch (tailError) {
|
|
190
201
|
const session = result?.session;
|
|
191
202
|
if (session && typeof session.abort === 'function') {
|
|
@@ -250,27 +250,73 @@ export function ssrfSafeLookup(
|
|
|
250
250
|
});
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
253
|
+
/**
|
|
254
|
+
* True when the error is one of our SSRF blocks — request-time validation
|
|
255
|
+
* (message contains "not allowed") or the connect-time DNS pin (`ESSRFBLOCKED`).
|
|
256
|
+
*
|
|
257
|
+
* The connect-time error never surfaces directly: undici wraps it in
|
|
258
|
+
* `TypeError: fetch failed` with the real error down the `cause` chain, so a
|
|
259
|
+
* message check on the outer error alone files a connect-time block as a generic
|
|
260
|
+
* fetch error. Walk the chain (bounded — causes can be cyclic) instead.
|
|
261
|
+
*/
|
|
262
|
+
export function isSsrfBlockError(error: unknown): boolean {
|
|
263
|
+
let e: unknown = error;
|
|
264
|
+
for (let depth = 0; depth < 5 && e instanceof Error; depth++) {
|
|
265
|
+
if ((e as NodeJS.ErrnoException).code === 'ESSRFBLOCKED' || e.message.includes('not allowed')) {
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
e = (e as { cause?: unknown }).cause;
|
|
269
|
+
}
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** A fetch-shaped callable plus the dispatcher it must be invoked with. */
|
|
274
|
+
export interface SsrfSafeFetcher {
|
|
275
|
+
/** undici's own fetch — NOT the global one. See getSsrfSafeFetcher. */
|
|
276
|
+
fetch: (url: string, init: Record<string, unknown>) => Promise<Response>;
|
|
277
|
+
dispatcher: unknown;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Cached after first construction. `undefined` = not yet attempted, `null` =
|
|
281
|
+
// undici unavailable (fall back to the global fetch with request-time
|
|
282
|
+
// validation only).
|
|
283
|
+
let cachedSsrfFetcher: SsrfSafeFetcher | null | undefined = undefined;
|
|
257
284
|
|
|
258
285
|
/**
|
|
259
286
|
* Build (once) an undici Agent whose connector resolves via ssrfSafeLookup, so
|
|
260
287
|
* every scrape fetch — including each manually-followed redirect hop — connects
|
|
261
|
-
* only to a validated public address
|
|
262
|
-
*
|
|
288
|
+
* only to a validated public address, PAIRED with the fetch implementation that
|
|
289
|
+
* can actually accept it.
|
|
290
|
+
*
|
|
291
|
+
* The pairing is the whole point. A dispatcher is only honoured by the undici
|
|
292
|
+
* that created it: Node's global `fetch` is backed by the undici BUILT INTO the
|
|
293
|
+
* runtime (6.21.0 on Node 22, 7.24.4 on Node 25), while this package depends on
|
|
294
|
+
* undici 8.x. Handing a v8 `Agent` to the global fetch throws
|
|
295
|
+
* `UND_ERR_INVALID_ARG: invalid onRequestStart method` on every request, on
|
|
296
|
+
* every supported Node — which is exactly what it did: the entire fetch scrape
|
|
297
|
+
* path failed instantly, every URL was retried once and then fell back to the
|
|
298
|
+
* stealth browser (~4.5s per page against ~250ms), and the connect-time SSRF pin
|
|
299
|
+
* this dispatcher exists for never engaged even once. The browser fallback made
|
|
300
|
+
* it invisible: scrapes still succeeded, just 15-20x slower.
|
|
301
|
+
*
|
|
302
|
+
* Returning the two together makes the invalid combination unrepresentable —
|
|
303
|
+
* a caller cannot end up holding a dispatcher without the fetch that honours it.
|
|
304
|
+
* Returns null if undici can't be loaded; callers then use the global fetch with
|
|
305
|
+
* no dispatcher (request-time validation still applies).
|
|
263
306
|
*/
|
|
264
|
-
export async function
|
|
265
|
-
if (
|
|
307
|
+
export async function getSsrfSafeFetcher(): Promise<SsrfSafeFetcher | null> {
|
|
308
|
+
if (cachedSsrfFetcher !== undefined) return cachedSsrfFetcher;
|
|
266
309
|
try {
|
|
267
|
-
const { Agent } = await import('undici');
|
|
268
|
-
|
|
310
|
+
const { Agent, fetch: undiciFetch } = await import('undici');
|
|
311
|
+
cachedSsrfFetcher = {
|
|
312
|
+
fetch: undiciFetch as unknown as SsrfSafeFetcher['fetch'],
|
|
313
|
+
dispatcher: new Agent({ connect: { lookup: ssrfSafeLookup } }),
|
|
314
|
+
};
|
|
269
315
|
} catch (e) {
|
|
270
316
|
logger.warn(`[Scrapers] undici unavailable; connect-time SSRF pinning disabled (request-time validation still active): ${e instanceof Error ? e.message : String(e)}`);
|
|
271
|
-
|
|
317
|
+
cachedSsrfFetcher = null;
|
|
272
318
|
}
|
|
273
|
-
return
|
|
319
|
+
return cachedSsrfFetcher;
|
|
274
320
|
}
|
|
275
321
|
|
|
276
322
|
/**
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
import {
|
|
24
24
|
FETCH_LAYER_TIMEOUT,
|
|
25
25
|
} from './types.ts';
|
|
26
|
-
import { getRandomUserAgent, extractDomain, validateUrlForSSRF, validateContent, createNativeMarkdownConverter, createJsMarkdownConverter,
|
|
26
|
+
import { getRandomUserAgent, extractDomain, validateUrlForSSRF, validateContent, createNativeMarkdownConverter, createJsMarkdownConverter, getSsrfSafeFetcher, formatErrorWithCause, isBenignScrapeFailure, isSsrfBlockError, } from './scraper-utils.ts';
|
|
27
27
|
import { isTransientError, abortableDelay } from './retry-utils.ts';
|
|
28
28
|
import { safeUnref } from '../utils/safe-unref.ts';
|
|
29
29
|
import { readBodyCapped, BodyTooLargeError } from '../utils/http-body.ts';
|
|
@@ -208,7 +208,7 @@ function decodeHtmlBody(bytes: Uint8Array, contentTypeHeader: string): string {
|
|
|
208
208
|
* counts are precisely what production incidents are diagnosed from.
|
|
209
209
|
*/
|
|
210
210
|
function trackFetchLayerFailure(url: string, error: unknown): void {
|
|
211
|
-
const isSsrf = error
|
|
211
|
+
const isSsrf = isSsrfBlockError(error);
|
|
212
212
|
errorTracker.trackError(error instanceof Error ? error : String(error), {
|
|
213
213
|
component: 'scrapers',
|
|
214
214
|
operation: 'fetch',
|
|
@@ -226,7 +226,12 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
|
|
|
226
226
|
// closing the DNS-rebinding TOCTOU gap between validateUrlForSSRF (request
|
|
227
227
|
// time) and fetch's own re-resolution (connect time). Null when undici is
|
|
228
228
|
// unavailable — request-time validation above still applies.
|
|
229
|
-
|
|
229
|
+
//
|
|
230
|
+
// The fetch implementation comes WITH the dispatcher and must be used with it:
|
|
231
|
+
// a dispatcher is only honoured by the undici that built it, and the global
|
|
232
|
+
// fetch is backed by Node's bundled undici, not ours. See getSsrfSafeFetcher.
|
|
233
|
+
const fetcher = await getSsrfSafeFetcher();
|
|
234
|
+
const doFetch = fetcher?.fetch ?? ((u: string, init: Record<string, unknown>) => fetch(u, init as RequestInit));
|
|
230
235
|
|
|
231
236
|
const controller = new AbortController();
|
|
232
237
|
const timeoutId = setTimeout(() => controller.abort(), FETCH_LAYER_TIMEOUT);
|
|
@@ -257,16 +262,16 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
|
|
|
257
262
|
const userAgent = getRandomUserAgent();
|
|
258
263
|
|
|
259
264
|
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
260
|
-
response = await
|
|
265
|
+
response = await doFetch(currentUrl, {
|
|
261
266
|
signal: controller.signal,
|
|
262
267
|
redirect: 'manual',
|
|
263
268
|
headers: {
|
|
264
269
|
'User-Agent': userAgent,
|
|
265
270
|
'Accept': 'text/html,application/xhtml+xml,application/pdf,*/*;q=0.8',
|
|
266
271
|
},
|
|
267
|
-
//
|
|
268
|
-
...(
|
|
269
|
-
}
|
|
272
|
+
// Only ever attached alongside the fetch that honours it (see above).
|
|
273
|
+
...(fetcher ? { dispatcher: fetcher.dispatcher } : {}),
|
|
274
|
+
});
|
|
270
275
|
|
|
271
276
|
if (response.status >= 300 && response.status < 400) {
|
|
272
277
|
const location = response.headers.get('location');
|
|
@@ -380,7 +385,7 @@ async function scrapeWithFetch(url: string, signal?: AbortSignal): Promise<Scrap
|
|
|
380
385
|
// call in a retry, and tracking per attempt would record one failure twice —
|
|
381
386
|
// inflating exactly the error counts these numbers are read to diagnose.
|
|
382
387
|
// scrapeSingle calls trackFetchLayerFailure once with the final error.
|
|
383
|
-
if (error
|
|
388
|
+
if (isSsrfBlockError(error)) {
|
|
384
389
|
metrics.increment('scrape_operations_total', 1, { layer: 'fetch', status: 'ssrf_blocked' });
|
|
385
390
|
metrics.observe('scrape_latency_ms', Date.now() - fetchStart, { layer: 'fetch', status: 'ssrf_blocked' });
|
|
386
391
|
throw error;
|