@lincoln504/pi-research 0.1.12 → 1.0.0
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/LICENSE +1 -1
- package/README.md +64 -115
- package/dist/cli.mjs +23211 -0
- package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
- package/dist/prompts/research-knowledge-search-extractor.md +41 -0
- package/dist/prompts/research-tool-usage.md +92 -0
- package/dist/prompts/researcher.md +89 -0
- package/dist/prompts/system-coordinator.md +75 -0
- package/dist/prompts/system-lead-evaluator.md +118 -0
- package/dist/thread-worker.mjs +2611 -0
- package/dist/webgpu-probe.mjs +80 -0
- package/docs/AGENT-SKILL.md +102 -0
- package/docs/ARCHITECTURE.md +306 -80
- package/docs/CONFIGURATION.md +249 -0
- package/docs/KNOWLEDGE-STORE.md +213 -0
- package/docs/PI-EXTENSION.md +91 -0
- package/docs/SDK.md +113 -0
- package/docs/THIRD-PARTY-NOTICES.md +64 -0
- package/package.json +102 -36
- package/scripts/build.cjs +140 -0
- package/scripts/cleanup.cjs +149 -0
- package/scripts/prepare.cjs +28 -0
- package/scripts/setup.cjs +134 -0
- package/skills/pi-research/README.md +90 -0
- package/skills/pi-research/SKILL.md +102 -0
- package/skills/pi-research/references/configuration.md +77 -0
- package/skills/pi-research/scripts/run.mjs +237 -0
- package/src/cleanup/research-cleanup.ts +140 -0
- package/src/cli.ts +1044 -0
- package/src/config.ts +1036 -147
- package/src/constants.ts +115 -84
- package/src/core/health-check-service.ts +265 -0
- package/src/core/interfaces/health-check-interfaces.ts +50 -0
- package/src/core/interfaces/knowledge-interfaces.ts +190 -0
- package/src/core/interfaces/observer-interfaces.ts +67 -0
- package/src/core/interfaces/orchestration-interfaces.ts +86 -0
- package/src/core/interfaces/planning-interfaces.ts +100 -0
- package/src/core/interfaces/process-interfaces.ts +37 -0
- package/src/core/interfaces/research-plan-types.ts +52 -0
- package/src/core/interfaces/scheduler-interfaces.ts +91 -0
- package/src/core/interfaces/service-names.ts +39 -0
- package/src/core/interfaces/state-manager-interfaces.ts +29 -0
- package/src/core/interfaces/steering-interfaces.ts +31 -0
- package/src/core/llm/agentic-repair.ts +169 -0
- package/src/core/llm/inject-date.ts +52 -0
- package/src/core/llm/llm-timeout.ts +64 -0
- package/src/core/llm/llm-utils.ts +86 -0
- package/src/core/llm/model-registry-factory.ts +287 -0
- package/src/core/llm/pi-ai-completion.ts +29 -0
- package/src/core/llm/prompts.ts +50 -0
- package/src/core/llm/research-model-resolver.ts +91 -0
- package/src/core/planning-constants.ts +13 -0
- package/src/core/planning-service.ts +649 -0
- package/src/core/planning-utils.ts +299 -0
- package/src/core/scheduler-factory.ts +48 -0
- package/src/core/scheduler-service.ts +123 -0
- package/src/core/service-initialization.ts +194 -0
- package/src/core/service-interfaces.ts +48 -0
- package/src/core/service-registry.ts +659 -0
- package/src/healthcheck/index.ts +271 -265
- package/src/healthcheck/registry.ts +130 -0
- package/src/index.ts +639 -50
- package/src/infrastructure/browser/browser-cleanup.ts +309 -0
- package/src/infrastructure/browser/browser-client.ts +199 -0
- package/src/infrastructure/browser/browser-error-utils.ts +160 -0
- package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
- package/src/infrastructure/browser/browser-server.ts +174 -0
- package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
- package/src/infrastructure/browser/cleanup-utils.ts +155 -0
- package/src/infrastructure/browser/client-agent.ts +31 -0
- package/src/infrastructure/browser/config.ts +288 -0
- package/src/infrastructure/browser/ensure-browser.ts +205 -0
- package/src/infrastructure/browser/index.ts +37 -0
- package/src/infrastructure/browser/priority-task-queue.ts +226 -0
- package/src/infrastructure/browser/scheduler-factory.ts +354 -0
- package/src/infrastructure/browser/task-execution-service.ts +209 -0
- package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
- package/src/infrastructure/browser/thread-worker-types.ts +25 -0
- package/src/infrastructure/browser/thread-worker.mjs +2611 -0
- package/src/infrastructure/browser/thread-worker.ts +168 -0
- package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
- package/src/infrastructure/embedding/embedding-client.ts +205 -0
- package/src/infrastructure/embedding/embedding-factory.ts +324 -0
- package/src/infrastructure/embedding/embedding-server.ts +535 -0
- package/src/infrastructure/file-lock-service.ts +592 -0
- package/src/infrastructure/gpu-resource-service.ts +191 -0
- package/src/infrastructure/knowledge-store-service.ts +475 -0
- package/src/infrastructure/metrics-service.ts +129 -0
- package/src/infrastructure/process-lifecycle-service.ts +253 -0
- package/src/infrastructure/scheduler-factory-service.ts +58 -0
- package/src/infrastructure/service-initialization.ts +292 -0
- package/src/infrastructure/state/state-backup-manager.ts +290 -0
- package/src/infrastructure/state/state-browser-api.ts +62 -0
- package/src/infrastructure/state/state-browser-manager.ts +70 -0
- package/src/infrastructure/state/state-manager-service.ts +264 -0
- package/src/infrastructure/state/state-manager.ts +547 -0
- package/src/infrastructure/state/state-metrics.ts +92 -0
- package/src/infrastructure/state/state-migration.ts +103 -0
- package/src/infrastructure/state/state-path-configuration.ts +119 -0
- package/src/infrastructure/state/state-session-api.ts +94 -0
- package/src/infrastructure/state/state-session-manager.ts +127 -0
- package/src/infrastructure/state/state-validator.ts +93 -0
- package/src/infrastructure/types/state-types.ts +85 -0
- package/src/knowledge/chunker.ts +157 -0
- package/src/knowledge/embedder-init.ts +413 -0
- package/src/knowledge/embedder-utils.ts +296 -0
- package/src/knowledge/embedder.ts +592 -0
- package/src/knowledge/index.ts +126 -0
- package/src/knowledge/lancedb-loader.ts +35 -0
- package/src/knowledge/migration.ts +17 -0
- package/src/knowledge/model-config.ts +132 -0
- package/src/knowledge/onnx-env.ts +48 -0
- package/src/knowledge/store-operations.ts +291 -0
- package/src/knowledge/store-schema.ts +79 -0
- package/src/knowledge/store.ts +1251 -0
- package/src/knowledge/transformers-loader.ts +31 -0
- package/src/knowledge/webgpu-probe.mjs +80 -0
- package/src/knowledge/webgpu-viability.ts +255 -0
- package/src/knowledge/writer-queue.ts +252 -0
- package/src/logger.ts +316 -122
- package/src/observers/research-observer-impl.ts +459 -0
- package/src/orchestration/deep-research-orchestrator.ts +524 -773
- package/src/orchestration/deep-research-types.ts +47 -35
- package/src/orchestration/headless-observer.ts +114 -0
- package/src/orchestration/orchestration-types.ts +33 -0
- package/src/orchestration/quick-research-orchestrator.ts +463 -0
- package/src/orchestration/research-orchestration-service.ts +549 -0
- package/src/orchestration/research-session-service.ts +178 -0
- package/src/orchestration/research-synthesis-service.ts +348 -0
- package/src/orchestration/researcher-executor.ts +394 -0
- package/src/orchestration/researcher.ts +115 -42
- package/src/orchestration/service-initialization.ts +109 -0
- package/src/orchestration/session-context.ts +7 -3
- package/src/orchestration/session-state.ts +672 -0
- package/src/prompts/research-knowledge-relevance-triage.md +35 -0
- package/src/prompts/research-knowledge-search-extractor.md +41 -0
- package/src/prompts/research-tool-usage.md +63 -16
- package/src/prompts/researcher.md +59 -136
- package/src/prompts/system-coordinator.md +64 -13
- package/src/prompts/system-lead-evaluator.md +82 -189
- package/src/research-config.ts +872 -0
- package/src/sdk.ts +847 -0
- package/src/security/cisa-kev.ts +72 -9
- package/src/security/cvss.ts +92 -0
- package/src/security/ecosystem.ts +45 -0
- package/src/security/github-advisories.ts +168 -428
- package/src/security/github-advisory-types.ts +282 -0
- package/src/security/index.ts +63 -177
- package/src/security/nvd-client.ts +550 -0
- package/src/security/nvd-types.ts +179 -0
- package/src/security/osv-client.ts +246 -0
- package/src/security/osv-types.ts +285 -0
- package/src/security/types.ts +67 -53
- package/src/skill-install/skill-installer.ts +519 -0
- package/src/stackexchange/index.ts +81 -27
- package/src/stackexchange/output/compact.ts +7 -6
- package/src/stackexchange/output/table.ts +11 -7
- package/src/stackexchange/queries.ts +0 -10
- package/src/stackexchange/rest-client.ts +141 -66
- package/src/stackexchange/types.ts +0 -17
- package/src/tool.ts +15 -423
- package/src/tools/grep.ts +32 -310
- package/src/tools/health-tool-definition.ts +101 -0
- package/src/tools/index.ts +38 -9
- package/src/tools/research-knowledge-search.ts +974 -0
- package/src/tools/research-knowledge-types.ts +77 -0
- package/src/tools/research-tool-definition.ts +494 -0
- package/src/tools/scrape.ts +286 -388
- package/src/tools/search.ts +98 -153
- package/src/tools/security.ts +107 -80
- package/src/tools/stackexchange.ts +58 -35
- package/src/tools/youtube-transcript.ts +163 -0
- package/src/tui/knowledge-search-panel.ts +86 -0
- package/src/tui/pulse.ts +86 -0
- package/src/tui/research-health.ts +124 -0
- package/src/tui/research-panel-color-utils.ts +340 -0
- package/src/tui/research-panel-format.ts +54 -0
- package/src/tui/research-panel-state.ts +404 -0
- package/src/tui/research-panel-wave.ts +280 -0
- package/src/tui/research-panel.ts +339 -435
- package/src/tui/research-tui-manager.ts +233 -0
- package/src/tui/tui-controller.ts +137 -0
- package/src/types/extension-context.ts +18 -49
- package/src/types/index.ts +61 -0
- package/src/types/llm.ts +68 -29
- package/src/types/research-panel-types.ts +61 -0
- package/src/utils/circuit-breaker.ts +146 -0
- package/src/utils/citation-utils.ts +97 -0
- package/src/utils/disk-space-checker.ts +55 -0
- package/src/utils/error-tracker.ts +265 -0
- package/src/utils/host-config.ts +45 -0
- package/src/utils/input-validation.ts +8 -10
- package/src/utils/json-utils.ts +150 -16
- package/src/utils/llm-usage.ts +54 -0
- package/src/utils/log-rotation.ts +125 -0
- package/src/utils/log-utils.ts +159 -0
- package/src/utils/make-resource-loader.ts +3 -1
- package/src/utils/metrics-summary.ts +459 -0
- package/src/utils/metrics.ts +302 -0
- package/src/utils/research-export.ts +28 -12
- package/src/utils/safe-unref.ts +31 -0
- package/src/utils/shared-links.ts +195 -90
- package/src/utils/shutdown-manager.ts +65 -0
- package/src/utils/stdio-capture.ts +407 -0
- package/src/utils/text-utils.ts +264 -7
- package/src/utils/tool-usage-tracker.ts +83 -16
- package/src/utils/url-utils.ts +255 -0
- package/src/utils/user-agent.ts +28 -0
- package/src/web-research/browser-search.ts +179 -0
- package/src/web-research/retry-utils.ts +173 -77
- package/src/web-research/scraper-types.ts +72 -0
- package/src/web-research/scraper-utils.ts +434 -0
- package/src/web-research/search.ts +78 -218
- package/src/web-research/types.ts +9 -24
- package/src/web-research/utils.ts +2 -143
- package/src/web-research/web-scraper.ts +477 -0
- package/src/youtube/index.ts +154 -0
- package/src/youtube/potoken.ts +259 -0
- package/src/youtube/transcript-client.ts +390 -0
- package/src/youtube/video-id.ts +118 -0
- package/config/searxng/default-settings.yml +0 -189
- package/config/searxng/limiter.toml +0 -34
- package/config/tooling/eslint.config.js +0 -73
- package/config/tooling/vitest.config.integration.ts +0 -17
- package/config/tooling/vitest.config.ts +0 -40
- package/config/tooling/vitest.config.unit.ts +0 -12
- package/docs/assets/README-banner.jpg +0 -0
- package/scripts/setup.js +0 -174
- package/src/infrastructure/searxng-lifecycle.ts +0 -577
- package/src/infrastructure/searxng-manager.ts +0 -1183
- package/src/infrastructure/state-manager.ts +0 -918
- package/src/orchestration/deep-research-reducer.ts +0 -129
- package/src/orchestration/id-utils.ts +0 -63
- package/src/security/nvd.ts +0 -532
- package/src/security/osv.ts +0 -487
- package/src/stackexchange/cache.ts +0 -111
- package/src/stackexchange/output/index.ts +0 -7
- package/src/stackexchange/output/json.ts +0 -21
- package/src/tui/searxng-status.ts +0 -80
- package/src/utils/inject-date.ts +0 -29
- package/src/utils/research-error.ts +0 -205
- package/src/utils/searxng-config.ts +0 -94
- package/src/utils/session-state.ts +0 -295
- package/src/web-research/dependencies.ts +0 -20
- package/src/web-research/scrapers.ts +0 -935
- package/tsconfig.json +0 -40
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Researcher Executor
|
|
3
|
+
*
|
|
4
|
+
* Handles execution of individual researchers
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ResearchMessage } from '../types/index.ts';
|
|
8
|
+
import type { SystemResearchState } from './deep-research-types.ts';
|
|
9
|
+
import { createResearcherSession } from './researcher.ts';
|
|
10
|
+
import { registerScrapedLinks } from '../utils/shared-links.ts';
|
|
11
|
+
import { ensureAssistantResponse } from '../utils/text-utils.ts';
|
|
12
|
+
import { extractUsage } from '../types/llm.ts';
|
|
13
|
+
import { logger } from '../logger.ts';
|
|
14
|
+
import { metrics } from '../utils/metrics.ts';
|
|
15
|
+
import { ServiceNames,
|
|
16
|
+
type IResearchSynthesisService,
|
|
17
|
+
} from '../core/service-interfaces.ts';
|
|
18
|
+
import { getService, tryGetServiceContainerFromCtx } from '../core/service-registry.ts';
|
|
19
|
+
import type { ResearchSessionService } from './research-session-service.ts';
|
|
20
|
+
import { loadPrompt } from '../core/llm/prompts.ts';
|
|
21
|
+
import { injectCurrentDate } from '../core/llm/inject-date.ts';
|
|
22
|
+
import { recordResearcherFailure, getSteeringMessages } from './session-state.ts';
|
|
23
|
+
import type { RunResearcherOptions } from './orchestration-types.ts';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Run a single researcher with retries
|
|
27
|
+
*/
|
|
28
|
+
export async function runResearcher(options: RunResearcherOptions): Promise<void> {
|
|
29
|
+
const {
|
|
30
|
+
config: researcherConfig,
|
|
31
|
+
initialLinks,
|
|
32
|
+
historicalUrls,
|
|
33
|
+
researchId,
|
|
34
|
+
round,
|
|
35
|
+
query,
|
|
36
|
+
complexity,
|
|
37
|
+
ctx,
|
|
38
|
+
model,
|
|
39
|
+
researchConfig: config,
|
|
40
|
+
planningService,
|
|
41
|
+
observer,
|
|
42
|
+
signal,
|
|
43
|
+
sessionId,
|
|
44
|
+
} = options;
|
|
45
|
+
|
|
46
|
+
const id = String(researcherConfig.id);
|
|
47
|
+
const container = tryGetServiceContainerFromCtx(ctx);
|
|
48
|
+
observer?.onResearcherStart?.(id, researcherConfig.name, researcherConfig.goal, round);
|
|
49
|
+
metrics.increment('researchers_launched_total', 1, { mode: 'deep', complexity: String(complexity), round: String(round) });
|
|
50
|
+
|
|
51
|
+
const currentPlan = planningService.getCurrentPlan(researchId);
|
|
52
|
+
const previousQueriesSection = currentPlan?.allQueries && currentPlan.allQueries.length > 0
|
|
53
|
+
? `\n### Previous Queries (Sibling Researchers)\n${currentPlan.allQueries.map((q: string) => `- ${q}`).join('\n')}\n`
|
|
54
|
+
: '';
|
|
55
|
+
|
|
56
|
+
let storeSection = '';
|
|
57
|
+
if (historicalUrls.length > 0) {
|
|
58
|
+
storeSection = '\n## Knowledge Store\n' +
|
|
59
|
+
'The following URLs were found in the knowledge store from previous research sessions. ' +
|
|
60
|
+
'Scrape each URL to retrieve its full current content. The summary below describes what was previously found at that URL — use it as a guide for what to expect.\n' +
|
|
61
|
+
historicalUrls.map(e => `- ${e.url}\n Previous summary: ${e.description}`).join('\n');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const researcherPromptTemplate = loadPrompt('researcher');
|
|
65
|
+
if (initialLinks.length === 0 && historicalUrls.length === 0) {
|
|
66
|
+
logger.warn(`[ResearcherExecutor] Researcher ${id} has no initial search results or historical links; skipping.`);
|
|
67
|
+
// Use the resolved sessionId (= piSessionId), NOT the raw ctx.sessionId: when
|
|
68
|
+
// ctx.sessionId is unset but sessionManager supplies a real id (common in SDK
|
|
69
|
+
// use), recording under ctx.sessionId would file this no-links skip under a
|
|
70
|
+
// different id than shouldStopResearch() checks, so the fast-stop guard would
|
|
71
|
+
// under-count exactly the broad-search-failure case it exists to catch.
|
|
72
|
+
recordResearcherFailure(sessionId, researchId, id);
|
|
73
|
+
metrics.increment('researcher_skipped_total', 1, { mode: 'deep', complexity: String(complexity), reason: 'no_initial_links' });
|
|
74
|
+
observer?.onResearcherFailure?.(id, 'No initial search results or historical links available');
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let evidenceSection = '';
|
|
79
|
+
if (initialLinks.length > 0) {
|
|
80
|
+
evidenceSection = `## Evidence Provided\nInitial search results provided the following URLs to investigate:\n${initialLinks.map(l => `- ${l}`).join('\n')}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const maxAttempts = config.RESEARCHER_MAX_RETRIES + 1;
|
|
84
|
+
let lastError: unknown;
|
|
85
|
+
const researcherExecutionStartMs = Date.now();
|
|
86
|
+
const deliveredSteeringIds = new Set<string>();
|
|
87
|
+
|
|
88
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
89
|
+
if (attempt > 1) {
|
|
90
|
+
const delay = Math.min(1000 * Math.pow(2, attempt - 2), config.RESEARCHER_MAX_RETRY_DELAY_MS);
|
|
91
|
+
logger.warn(`[ResearcherExecutor] Researcher ${id} retry ${attempt - 1}/${config.RESEARCHER_MAX_RETRIES} after ${delay}ms`);
|
|
92
|
+
observer?.onResearcherProgress?.(id, 'retry');
|
|
93
|
+
await new Promise(r => setTimeout(r, delay));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Generate prompt for this attempt, incorporating ALL steering messages delivered so far
|
|
97
|
+
const allSteering = getSteeringMessages(sessionId);
|
|
98
|
+
let steeringSection = '';
|
|
99
|
+
if (allSteering.length > 0) {
|
|
100
|
+
steeringSection = '\n\n### ADDITIONAL USER GUIDANCE (Mandatory directional rules for your research)\n' +
|
|
101
|
+
allSteering.map(m => {
|
|
102
|
+
deliveredSteeringIds.add(m.id);
|
|
103
|
+
return `- ${m.text}`;
|
|
104
|
+
}).join('\n');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const prompt = injectCurrentDate(researcherPromptTemplate, 'researcher')
|
|
108
|
+
// Function replacers so a `$`-bearing dynamic value (scraped evidence, store
|
|
109
|
+
// descriptions, or an LLM-authored goal containing $&, $$, $1, …) is inserted
|
|
110
|
+
// literally instead of being interpreted as a String.replace substitution pattern.
|
|
111
|
+
.replace('{{goal}}', () => researcherConfig.goal)
|
|
112
|
+
.replace('{{store_section}}', () => storeSection)
|
|
113
|
+
.replace('{{evidence_section}}', () => evidenceSection)
|
|
114
|
+
.replace('{{coordination_section}}', () => previousQueriesSection)
|
|
115
|
+
.replace('{{extra_tool_guidelines}}', '')
|
|
116
|
+
.trim() + steeringSection;
|
|
117
|
+
|
|
118
|
+
logger.debug(`[ResearcherExecutor] Researcher ${id} attempt ${attempt} System Prompt:\n${prompt}`);
|
|
119
|
+
|
|
120
|
+
const workerExclude = ['search'];
|
|
121
|
+
const mergedExclude = [...new Set([...workerExclude, ...(options.excludeTools || [])])];
|
|
122
|
+
|
|
123
|
+
// Grounding gate: when the scrape tool is enabled for this researcher, a report grounded in
|
|
124
|
+
// NO real source (and no knowledge-store summaries to fall back on) is ungrounded.
|
|
125
|
+
// A researcher is grounded when it retrieved real content from any content-retrieval tool:
|
|
126
|
+
// - successfulScrapeCount: successes the scrape tool reports (fresh fetches AND cache hits)
|
|
127
|
+
// via the onUrlScrapeResult callback below. youtube_transcript routes its per-video
|
|
128
|
+
// successes through the SAME callback, so transcript fetches count here too.
|
|
129
|
+
// - nonScrapeGroundingHits: real items returned by the non-URL grounding tools
|
|
130
|
+
// (security_search, stackexchange), reported uniformly as details.groundingHits on the
|
|
131
|
+
// tool_execution_end event. (grep is local code search, not a web-research source, and
|
|
132
|
+
// wraps an opaque SDK result, so it is intentionally NOT a grounding signal.)
|
|
133
|
+
// - historicalUrls: knowledge-store summaries supplied to this researcher.
|
|
134
|
+
// Per-attempt: each retry builds a fresh session, so both counts reset at the top of the body.
|
|
135
|
+
//
|
|
136
|
+
// The gate is a PRODUCTION guard against ungrounded real research. It does not apply when
|
|
137
|
+
// scraping is mocked: PI_RESEARCH_MOCK_SCRAPE (a TEST-ONLY mode, not a product feature)
|
|
138
|
+
// serves a tiny stub that never passes content validation, so there are zero real scrapes
|
|
139
|
+
// by design — applying the gate there would fail every researcher even with a capable model
|
|
140
|
+
// (which is exactly what made mocked demo/test runs look like "the model can't research").
|
|
141
|
+
const scrapeEnabled = !mergedExclude.includes('scrape') && process.env['PI_RESEARCH_MOCK_SCRAPE'] !== 'true';
|
|
142
|
+
let successfulScrapeCount = 0;
|
|
143
|
+
let nonScrapeGroundingHits = 0;
|
|
144
|
+
|
|
145
|
+
const { session, resolvedModel } = await createResearcherSession({
|
|
146
|
+
cwd: ctx.cwd,
|
|
147
|
+
ctxModel: model,
|
|
148
|
+
modelRegistry: ctx.modelRegistry,
|
|
149
|
+
systemPrompt: prompt,
|
|
150
|
+
extensionCtx: ctx,
|
|
151
|
+
excludeTools: mergedExclude,
|
|
152
|
+
researcherId: id,
|
|
153
|
+
config,
|
|
154
|
+
getGlobalState: (): SystemResearchState => ({
|
|
155
|
+
version: 1,
|
|
156
|
+
researchId,
|
|
157
|
+
rootQuery: query,
|
|
158
|
+
complexity,
|
|
159
|
+
currentRound: round,
|
|
160
|
+
status: 'researching',
|
|
161
|
+
lastUpdated: Date.now(),
|
|
162
|
+
initialAgenda: [],
|
|
163
|
+
allScrapedLinks: [],
|
|
164
|
+
aspects: {},
|
|
165
|
+
}),
|
|
166
|
+
updateGlobalLinks: (links) => registerScrapedLinks(researchId, links),
|
|
167
|
+
onSearchProgress: (links) => {
|
|
168
|
+
observer?.onResearcherProgress?.(id, `${links} results`);
|
|
169
|
+
},
|
|
170
|
+
onUrlScrapeResult: (_url, success) => {
|
|
171
|
+
if (success) successfulScrapeCount++;
|
|
172
|
+
observer?.onToolResult?.(id, success);
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Hand the freshly-created AgentSession to the cleanup machinery before any
|
|
177
|
+
// other work. createResearcherSession() above already allocated a live
|
|
178
|
+
// session; if resolving/registering the session service throws (container
|
|
179
|
+
// disposed mid-run, race), the session is not yet owned by
|
|
180
|
+
// cleanupResearchServices and would leak unaborted. Abort it directly on any
|
|
181
|
+
// failure in this window, then rethrow. (Same acquire-before-teardown-wired
|
|
182
|
+
// class as the ghost-panel fix.)
|
|
183
|
+
// Undefined until the registration below succeeds: if getService throws, the
|
|
184
|
+
// finally must NOT dereference it (that TypeError would mask the real error).
|
|
185
|
+
let sessionService: ResearchSessionService | undefined;
|
|
186
|
+
try {
|
|
187
|
+
sessionService = await getService<ResearchSessionService>(ServiceNames.RESEARCH_SESSION_SERVICE, ctx, container);
|
|
188
|
+
sessionService.registerSession(researchId, id, session, () => session.abort().catch((err) => logger.warn('[ResearcherExecutor] Session abort failed:', err)));
|
|
189
|
+
} catch (registrationError) {
|
|
190
|
+
await session.abort().catch(() => { /* best-effort: session is being discarded */ });
|
|
191
|
+
throw registrationError;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let lastSteeringCheck = Date.now();
|
|
195
|
+
|
|
196
|
+
// User-supplied observer callbacks (SDK consumers) are invoked from inside
|
|
197
|
+
// session event dispatch — a throw there would propagate into the session's
|
|
198
|
+
// event loop. Isolate them the way TuiPulse isolates its subscribers.
|
|
199
|
+
const safeObserve = (fn: () => void): void => {
|
|
200
|
+
try { fn(); } catch (err) { logger.debug('[ResearcherExecutor] Observer callback threw:', err); }
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const subscription = session.subscribe((event: any) => {
|
|
204
|
+
// Check for new steering messages periodically
|
|
205
|
+
const now = Date.now();
|
|
206
|
+
if (now - lastSteeringCheck > 500) {
|
|
207
|
+
lastSteeringCheck = now;
|
|
208
|
+
const allSteering = getSteeringMessages(sessionId);
|
|
209
|
+
for (const msg of allSteering) {
|
|
210
|
+
if (!deliveredSteeringIds.has(msg.id)) {
|
|
211
|
+
deliveredSteeringIds.add(msg.id);
|
|
212
|
+
session.steer(msg.text).catch(e => logger.warn('[ResearcherExecutor] Failed to deliver steering:', e));
|
|
213
|
+
logger.debug(`[ResearcherExecutor] Delivered mid-flight steering message to ${id}: ${msg.text}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (event.type === 'message_update') {
|
|
219
|
+
const ame = event.assistantMessageEvent as any;
|
|
220
|
+
if (ame?.type === 'start') {
|
|
221
|
+
const inputTokens: number = ame.partial?.usage?.input ?? 0;
|
|
222
|
+
if (inputTokens > 0) {
|
|
223
|
+
safeObserve(() => observer?.onResearcherTokensHint?.(id, inputTokens));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} else if (event.type === 'message_end') {
|
|
227
|
+
const msg = event.message as unknown as ResearchMessage;
|
|
228
|
+
if (msg?.['role'] !== 'assistant') return;
|
|
229
|
+
|
|
230
|
+
const rawUsage = msg['usage'] as any;
|
|
231
|
+
if (rawUsage) {
|
|
232
|
+
const { tokens, cost } = extractUsage(resolvedModel, rawUsage);
|
|
233
|
+
|
|
234
|
+
if (tokens > 0 || cost > 0) {
|
|
235
|
+
metrics.increment('llm_tokens_total', tokens, { component: 'researcher', complexity: String(complexity) });
|
|
236
|
+
metrics.increment('llm_cost_total', cost, { component: 'researcher', complexity: String(complexity) });
|
|
237
|
+
safeObserve(() => observer?.onResearcherProgress?.(id, undefined, tokens, cost));
|
|
238
|
+
safeObserve(() => observer?.onTokensConsumed?.(tokens, cost));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
} else if (event.type === 'tool_execution_start') {
|
|
242
|
+
safeObserve(() => observer?.onResearcherProgress?.(id, `${event.toolName}`));
|
|
243
|
+
} else if (event.type === 'tool_execution_end') {
|
|
244
|
+
safeObserve(() => observer?.onResearcherProgress?.(id, `done:${event.toolName}`));
|
|
245
|
+
// Per-tool flash for non-scrape tools (scrape uses per-URL callback instead)
|
|
246
|
+
if (event.toolName !== 'scrape') {
|
|
247
|
+
safeObserve(() => observer?.onToolResult?.(id, !event.isError));
|
|
248
|
+
}
|
|
249
|
+
// Grounding accumulation for the non-URL grounding tools. They don't go through
|
|
250
|
+
// onUrlScrapeResult, so they report how many real items they retrieved via a uniform
|
|
251
|
+
// details.groundingHits field (e.g. security_search = vulnerabilities found,
|
|
252
|
+
// stackexchange = questions/answers returned). Soft-failures (rate-limit, API error,
|
|
253
|
+
// empty result) omit it or report 0, so they correctly do NOT count as grounding.
|
|
254
|
+
const details = (event.result as { details?: { groundingHits?: unknown } } | undefined)?.details;
|
|
255
|
+
const hits = details?.groundingHits;
|
|
256
|
+
if (typeof hits === 'number' && hits > 0) {
|
|
257
|
+
nonScrapeGroundingHits += hits;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
let timeoutId: NodeJS.Timeout | undefined;
|
|
264
|
+
const timeoutPromise = new Promise<void>((_, reject) => {
|
|
265
|
+
timeoutId = setTimeout(() => {
|
|
266
|
+
const msg = `Researcher ${id} (${researcherConfig.name}) timed out after ${config.RESEARCHER_TIMEOUT_MS}ms`;
|
|
267
|
+
session.abort().catch((err) => {
|
|
268
|
+
logger.warn('[ResearcherExecutor] Failed to abort timed-out researcher session:', err);
|
|
269
|
+
}).finally(() => reject(new Error(msg)));
|
|
270
|
+
}, config.RESEARCHER_TIMEOUT_MS);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
let abortCleanup: (() => void) | undefined;
|
|
274
|
+
try {
|
|
275
|
+
const promptPromise = session.prompt(`Topic: ${researcherConfig.name}\nGoal: ${researcherConfig.goal}\n\nPerform your research and submit your full report now.`);
|
|
276
|
+
promptPromise.catch((err: Error) => logger.debug(`[ResearcherExecutor] Background session prompt rejection: ${err.message}`));
|
|
277
|
+
await Promise.race([
|
|
278
|
+
promptPromise,
|
|
279
|
+
timeoutPromise,
|
|
280
|
+
...(signal ? [
|
|
281
|
+
new Promise<never>((_, reject) => {
|
|
282
|
+
const onAbort = () => {
|
|
283
|
+
session.abort().catch(err => logger.warn('[ResearcherExecutor] Failed to abort session on signal:', err));
|
|
284
|
+
reject(new Error('Aborted'));
|
|
285
|
+
};
|
|
286
|
+
if (signal.aborted) {
|
|
287
|
+
onAbort();
|
|
288
|
+
} else {
|
|
289
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
290
|
+
abortCleanup = () => signal.removeEventListener('abort', onAbort);
|
|
291
|
+
}
|
|
292
|
+
})
|
|
293
|
+
] : []),
|
|
294
|
+
]);
|
|
295
|
+
} finally {
|
|
296
|
+
clearTimeout(timeoutId);
|
|
297
|
+
abortCleanup?.();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const responseText = ensureAssistantResponse(session, id);
|
|
301
|
+
const researcherDuration = Date.now() - researcherExecutionStartMs;
|
|
302
|
+
metrics.observe('researcher_execution_latency_ms', researcherDuration, { mode: 'deep', complexity: String(complexity), round: String(round) });
|
|
303
|
+
logger.debug(`[ResearcherExecutor] Researcher ${id} Final Response:\n${responseText}`);
|
|
304
|
+
|
|
305
|
+
// Grounding gate (see scrapeEnabled / successfulScrapeCount above). A deep researcher
|
|
306
|
+
// cannot search (workerExclude = ['search']); scrape is its only path to real sources.
|
|
307
|
+
// If scrape was available but produced zero successful fetches AND no knowledge-store
|
|
308
|
+
// summaries were supplied (historicalUrls), the report is not grounded in any source —
|
|
309
|
+
// do NOT land it. Retry first (a transiently blocked scrape may recover); on the final
|
|
310
|
+
// attempt this falls through to `throw lastError`, which runResearchers catches and
|
|
311
|
+
// records as a researcher failure (same contract as retry-exhaustion).
|
|
312
|
+
if (scrapeEnabled && successfulScrapeCount === 0 && nonScrapeGroundingHits === 0 && historicalUrls.length === 0) {
|
|
313
|
+
lastError = new Error(`Researcher ${id} produced an ungrounded report: scrape tool enabled but zero successful scrapes and no other grounding`);
|
|
314
|
+
metrics.increment('researcher_ungrounded_total', 1, { mode: 'deep', complexity: String(complexity), round: String(round) });
|
|
315
|
+
// Do NOT retry an ungrounded report when the run is being torn down (quit/SIGTERM sets
|
|
316
|
+
// container.isDisposing without necessarily aborting the signal). A retry rebuilds the
|
|
317
|
+
// session and relaunches a search/scrape burst into a WorkerPool that dispose() is
|
|
318
|
+
// destroying — the same "Cannot execute a task on destroying pool" storm the catch-block
|
|
319
|
+
// guard prevents. This branch exits via `continue`, so it needs the guard too.
|
|
320
|
+
if (signal?.aborted || container?.isDisposing) {
|
|
321
|
+
logger.debug(`[ResearcherExecutor] Researcher ${id} ungrounded but run aborting/disposing; skipping retries.`);
|
|
322
|
+
break;
|
|
323
|
+
}
|
|
324
|
+
logger.warn(`[ResearcherExecutor] Researcher ${id} attempt ${attempt}/${maxAttempts} ungrounded (scrape enabled, 0 successful scrapes, 0 security/stackexchange grounding hits, no knowledge-store grounding); ${attempt < maxAttempts ? 'retrying' : 'failing'}`);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
|
|
329
|
+
synthesisService.storeReport(researchId, `${round}.${id}`, responseText);
|
|
330
|
+
|
|
331
|
+
observer?.onResearcherComplete?.(id, responseText);
|
|
332
|
+
return;
|
|
333
|
+
} catch (err) {
|
|
334
|
+
const researcherDuration = Date.now() - researcherExecutionStartMs;
|
|
335
|
+
metrics.observe('researcher_execution_latency_ms', researcherDuration, { mode: 'deep', complexity: String(complexity), round: String(round), status: 'error' });
|
|
336
|
+
metrics.increment('researcher_errors_total', 1, { mode: 'deep', complexity: String(complexity), round: String(round) });
|
|
337
|
+
lastError = err;
|
|
338
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
339
|
+
|
|
340
|
+
// Attempt to salvage partial content on timeout or error
|
|
341
|
+
try {
|
|
342
|
+
const partialResponse = ensureAssistantResponse(session, id);
|
|
343
|
+
// Apply the same grounding gate to salvaged partials: a timed-out/errored researcher
|
|
344
|
+
// with zero successful scrapes, zero non-scrape grounding hits, and no knowledge-store
|
|
345
|
+
// grounding has nothing real to land, so skip the salvage store and fall through to the
|
|
346
|
+
// retry/abort handling below.
|
|
347
|
+
const ungrounded = scrapeEnabled && successfulScrapeCount === 0 && nonScrapeGroundingHits === 0 && historicalUrls.length === 0;
|
|
348
|
+
if (partialResponse && partialResponse.trim().length > 50 && !ungrounded) {
|
|
349
|
+
const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
|
|
350
|
+
synthesisService.storeReport(researchId, `${round}.${id}`, partialResponse + '\n\n---\n*WARNING: This report was truncated due to a timeout/error. Content may be incomplete.*');
|
|
351
|
+
logger.log(`[ResearcherExecutor] Researcher ${id} salvaged partial content (${partialResponse.length} chars) after error: ${errMsg}`);
|
|
352
|
+
observer?.onResearcherComplete?.(id, partialResponse);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
} catch (salvageErr) {
|
|
356
|
+
logger.debug(`[ResearcherExecutor] Researcher ${id} salvage attempt failed:`, salvageErr);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Skip retries when the run is being torn down: an aborted signal, an explicit
|
|
360
|
+
// "Aborted" error, or the service container entering disposal (SIGTERM/quit
|
|
361
|
+
// mid-run, which throws "…during container disposal" from getService). Retrying
|
|
362
|
+
// during disposal is futile — services are being destroyed — and it relaunches
|
|
363
|
+
// search bursts into a WorkerPool that dispose() is simultaneously destroying,
|
|
364
|
+
// producing a storm of "Cannot execute a task on destroying pool" errors.
|
|
365
|
+
if (signal?.aborted || errMsg === 'Aborted' || errMsg.includes('during container disposal') || container?.isDisposing) {
|
|
366
|
+
logger.debug(`[ResearcherExecutor] Researcher ${id} aborted or container disposing, skipping retries.`);
|
|
367
|
+
break; // Break out of the attempt loop
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (attempt < maxAttempts) {
|
|
371
|
+
logger.warn(`[ResearcherExecutor] Researcher ${id} attempt ${attempt} failed: ${errMsg}; will retry`);
|
|
372
|
+
} else {
|
|
373
|
+
logger.error(`[ResearcherExecutor] Researcher ${id} failed all ${maxAttempts} attempts: ${errMsg}`);
|
|
374
|
+
metrics.increment('researcher_retries_exhausted_total', 1, { mode: 'deep', complexity: String(complexity) });
|
|
375
|
+
}
|
|
376
|
+
} finally {
|
|
377
|
+
subscription();
|
|
378
|
+
await session.abort().catch((err) => {
|
|
379
|
+
logger.warn(`[ResearcherExecutor] Failed to abort researcher session ${id}:`, err);
|
|
380
|
+
});
|
|
381
|
+
sessionService?.unregisterSession(researchId, id);
|
|
382
|
+
|
|
383
|
+
// Restore the default thinking label now that the researcher is done.
|
|
384
|
+
// Otherwise "Researcher X" persists for all subsequent agent turns.
|
|
385
|
+
// Symmetric with where the label is set (researcher.ts): a no-op in the
|
|
386
|
+
// research TUI, where the label is never applied in the first place.
|
|
387
|
+
if (ctx.mode !== 'tui' && ctx.hasUI && typeof ctx.ui.setHiddenThinkingLabel === 'function') {
|
|
388
|
+
ctx.ui.setHiddenThinkingLabel();
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
throw lastError;
|
|
394
|
+
}
|
|
@@ -3,102 +3,175 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Responsible for instantiating individual researcher agent sessions.
|
|
5
5
|
* Each researcher is an autonomous agent equipped with a suite of specialized tools:
|
|
6
|
-
* - Web search via
|
|
6
|
+
* - Web search via DuckDuckGo Lite
|
|
7
7
|
* - Context-aware URL scraping via Playwright
|
|
8
8
|
* - Security vulnerability database queries (NVD, CISA, OSV)
|
|
9
9
|
* - Technical Q&A retrieval from Stack Exchange
|
|
10
10
|
* - Local code search via Ripgrep
|
|
11
|
+
* - Knowledge store search for historical data
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
|
-
import type { AgentSession, ModelRegistry,
|
|
14
|
-
import { createAgentSession,
|
|
15
|
-
import type { Model } from '@
|
|
14
|
+
import type { AgentSession, ModelRegistry, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
15
|
+
import { createAgentSession, SessionManager, SettingsManager as SettingsManagerClass } from '@earendil-works/pi-coding-agent';
|
|
16
|
+
import type { Model } from '@earendil-works/pi-ai';
|
|
16
17
|
import { createResearchTools } from '../tools/index.ts';
|
|
17
18
|
import { makeResourceLoader } from '../utils/make-resource-loader.ts';
|
|
18
19
|
import { ToolUsageTracker, createDefaultToolLimits } from '../utils/tool-usage-tracker.ts';
|
|
19
20
|
import type { SystemResearchState } from './deep-research-types.ts';
|
|
21
|
+
import type { Config } from '../config.ts';
|
|
22
|
+
import { logger } from '../logger.ts';
|
|
23
|
+
|
|
24
|
+
import { resolveResearchModel } from '../core/llm/research-model-resolver.ts';
|
|
20
25
|
|
|
21
26
|
export interface CreateResearcherSessionOptions {
|
|
22
27
|
cwd: string;
|
|
23
28
|
ctxModel: Model<any> | undefined;
|
|
24
29
|
modelRegistry: ModelRegistry;
|
|
25
|
-
settingsManager: SettingsManager;
|
|
26
30
|
systemPrompt: string;
|
|
27
|
-
searxngUrl: string;
|
|
28
31
|
extensionCtx: ExtensionContext;
|
|
29
32
|
// Optional: real closures for global state management
|
|
30
33
|
getGlobalState?: () => SystemResearchState;
|
|
31
34
|
updateGlobalLinks?: (links: string[]) => void;
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
|
|
35
|
+
/** Optional researcher ID for per-researcher scrape tracking. */
|
|
36
|
+
researcherId?: string;
|
|
37
|
+
/** Callback invoked during search with cumulative link count found so far */
|
|
38
|
+
onSearchProgress?: (links: number) => void;
|
|
39
|
+
/** Fires for each individual URL scrape result (per-URL TUI flash). */
|
|
40
|
+
onUrlScrapeResult?: (url: string, success: boolean) => void;
|
|
41
|
+
/** List of tool names to disable for this researcher. */
|
|
42
|
+
excludeTools?: string[];
|
|
43
|
+
config?: Config;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ResolvedResearcherSession {
|
|
47
|
+
session: AgentSession;
|
|
48
|
+
/** The model actually used for this session (after RESEARCH_MODEL resolution). */
|
|
49
|
+
resolvedModel: Model<any>;
|
|
36
50
|
}
|
|
37
51
|
|
|
38
|
-
export async function createResearcherSession(options: CreateResearcherSessionOptions): Promise<
|
|
52
|
+
export async function createResearcherSession(options: CreateResearcherSessionOptions): Promise<ResolvedResearcherSession> {
|
|
39
53
|
const {
|
|
40
54
|
cwd,
|
|
41
55
|
ctxModel,
|
|
42
56
|
modelRegistry,
|
|
43
|
-
settingsManager,
|
|
44
57
|
systemPrompt,
|
|
45
|
-
searxngUrl,
|
|
46
58
|
extensionCtx,
|
|
47
59
|
getGlobalState,
|
|
48
60
|
updateGlobalLinks,
|
|
49
|
-
|
|
50
|
-
|
|
61
|
+
researcherId,
|
|
62
|
+
onSearchProgress,
|
|
63
|
+
onUrlScrapeResult,
|
|
64
|
+
excludeTools = [],
|
|
65
|
+
config,
|
|
51
66
|
} = options;
|
|
52
67
|
|
|
53
|
-
// Validate required parameters
|
|
54
|
-
if (!ctxModel) {
|
|
55
|
-
throw new Error('No model selected. Please select a model before using the research tool.');
|
|
56
|
-
}
|
|
57
|
-
|
|
58
68
|
if (!systemPrompt || typeof systemPrompt !== 'string') {
|
|
59
69
|
throw new Error('Invalid system prompt: must be a non-empty string');
|
|
60
70
|
}
|
|
61
71
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
72
|
+
// Create a mutable reference to the session so the tools can access it
|
|
73
|
+
// before the createAgentSession call returns.
|
|
74
|
+
const sessionRef: { session: AgentSession | null } = { session: null };
|
|
65
75
|
|
|
66
|
-
//
|
|
67
|
-
const
|
|
76
|
+
// Resolve model using centralized priority logic
|
|
77
|
+
const modelToUse = resolveResearchModel({
|
|
78
|
+
modelRegistry,
|
|
79
|
+
config,
|
|
80
|
+
hostModel: ctxModel,
|
|
81
|
+
cwd,
|
|
82
|
+
});
|
|
68
83
|
|
|
69
|
-
|
|
70
|
-
const globalState = getGlobalState || (() => ({} as any));
|
|
71
|
-
const globalLinks = updateGlobalLinks || (() => {});
|
|
84
|
+
logger.info(`[Researcher] Using model for researcher ${researcherId}: ${modelToUse.provider}/${modelToUse.id}`);
|
|
72
85
|
|
|
73
86
|
try {
|
|
87
|
+
const tracker = new ToolUsageTracker(createDefaultToolLimits(config));
|
|
88
|
+
|
|
89
|
+
// Use provided closures or fallback to safe dummies
|
|
90
|
+
const globalLinks = updateGlobalLinks || (() => {});
|
|
91
|
+
|
|
92
|
+
const customTools = createResearchTools({
|
|
93
|
+
cwd,
|
|
94
|
+
ctx: extensionCtx,
|
|
95
|
+
tracker,
|
|
96
|
+
getGlobalState,
|
|
97
|
+
updateGlobalLinks: globalLinks,
|
|
98
|
+
researcherId,
|
|
99
|
+
onSearchProgress: onSearchProgress,
|
|
100
|
+
onUrlScrapeResult: onUrlScrapeResult,
|
|
101
|
+
getTokensUsed: () => {
|
|
102
|
+
if (!sessionRef.session) return 0;
|
|
103
|
+
|
|
104
|
+
// Prefer getContextUsage as it accounts for the full context window history
|
|
105
|
+
if (typeof (sessionRef.session as any).getContextUsage === 'function') {
|
|
106
|
+
const usage = (sessionRef.session as any).getContextUsage();
|
|
107
|
+
if (usage && typeof usage.tokens === 'number') return usage.tokens;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Fallback to cumulative session usage
|
|
111
|
+
if (typeof (sessionRef.session as any).getUsage === 'function') {
|
|
112
|
+
const usage = (sessionRef.session as any).getUsage();
|
|
113
|
+
return (usage.input || 0) + (usage.output || 0);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return 0;
|
|
117
|
+
},
|
|
118
|
+
contextWindowSize: modelToUse.contextWindow,
|
|
119
|
+
config,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Explicit allowlist of custom tools minus dangerous built-ins.
|
|
123
|
+
const defaultExclude = ['bash', 'write', 'edit', 'repl', 'git', 'terminal'];
|
|
124
|
+
const mergedExclude = [...new Set([...defaultExclude, ...excludeTools])];
|
|
125
|
+
|
|
126
|
+
const tools = customTools.map(t => t.name).filter(name => !mergedExclude.includes(name));
|
|
127
|
+
|
|
128
|
+
// Build a researcher-scoped settings manager with provider retries restored to 2.
|
|
129
|
+
// v0.76.0 changed the SDK default to 0; transient network errors during long researcher
|
|
130
|
+
// runs would otherwise fail the entire researcher rather than retrying the LLM call.
|
|
131
|
+
const researcherSettings = SettingsManagerClass.inMemory({ retry: { provider: { maxRetries: 2 } } });
|
|
132
|
+
|
|
74
133
|
const result = await createAgentSession({
|
|
75
134
|
cwd,
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
searxngUrl,
|
|
79
|
-
ctx: extensionCtx,
|
|
80
|
-
tracker,
|
|
81
|
-
getGlobalState: globalState,
|
|
82
|
-
updateGlobalLinks: globalLinks,
|
|
83
|
-
getTokensUsed,
|
|
84
|
-
contextWindowSize,
|
|
85
|
-
}),
|
|
135
|
+
customTools,
|
|
136
|
+
tools, // Explicit allowlist (BUG-1 fix)
|
|
86
137
|
sessionManager: SessionManager.inMemory(), // Each researcher gets its own isolated session
|
|
87
|
-
settingsManager,
|
|
88
|
-
model:
|
|
138
|
+
settingsManager: researcherSettings,
|
|
139
|
+
model: modelToUse,
|
|
89
140
|
modelRegistry,
|
|
90
141
|
resourceLoader: makeResourceLoader(systemPrompt),
|
|
142
|
+
// Thinking/reasoning is disabled for researcher agents: they do retrieval and
|
|
143
|
+
// synthesis from scraped pages, not open-ended reasoning. Keeping it off reduces
|
|
144
|
+
// latency and token cost for at most a marginal quality gain in this use case.
|
|
145
|
+
// Honors the system-wide PI_RESEARCH_LLM_THINKING_LEVEL override (default 'off').
|
|
146
|
+
// pi-coding-agent clamps the level to whatever the chosen model supports.
|
|
147
|
+
thinkingLevel: config?.LLM_THINKING_LEVEL ?? 'off',
|
|
91
148
|
});
|
|
92
149
|
|
|
150
|
+
// Customize the agent thinking label for researchers (e.g. "Researcher 1.1")
|
|
151
|
+
// so non-TUI surfaces can tell concurrent researchers apart. Skip this in the
|
|
152
|
+
// research TUI (mode === 'tui'): the panel already shows every researcher, and
|
|
153
|
+
// the host renders this label as dim/italic thinking text ABOVE the panel —
|
|
154
|
+
// stray output the panel was meant to replace. Use the researcherId passed by
|
|
155
|
+
// the caller (not regex on the prompt, since the ID is never templated in).
|
|
156
|
+
if (extensionCtx.mode !== 'tui' && extensionCtx.hasUI && typeof extensionCtx.ui.setHiddenThinkingLabel === 'function') {
|
|
157
|
+
extensionCtx.ui.setHiddenThinkingLabel(
|
|
158
|
+
researcherId ? `Researcher ${researcherId}` : undefined
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
logger.log(`[Researcher] Created session with model=${modelToUse?.id || 'unknown'}`);
|
|
163
|
+
|
|
93
164
|
if (!result || !result.session) {
|
|
94
165
|
throw new Error('Session creation returned invalid result');
|
|
95
166
|
}
|
|
96
167
|
|
|
97
|
-
|
|
168
|
+
sessionRef.session = result.session;
|
|
169
|
+
|
|
170
|
+
return { session: result.session, resolvedModel: modelToUse };
|
|
98
171
|
} catch (error) {
|
|
99
172
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
100
173
|
throw new Error(`Failed to create researcher session: ${errorMsg}`, {
|
|
101
174
|
cause: error,
|
|
102
175
|
});
|
|
103
176
|
}
|
|
104
|
-
}
|
|
177
|
+
}
|