@lincoln504/pi-research 1.4.3 → 1.5.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/README.md +1 -1
- package/dist/cli.mjs +192 -45
- package/dist/prompts/research-knowledge-relevance-triage.md +2 -0
- package/dist/prompts/research-knowledge-search-extractor.md +2 -0
- package/dist/prompts/researcher.md +2 -14
- package/dist/thread-worker.mjs +1 -1
- package/docs/AGENT-SKILL.md +4 -3
- package/docs/CHANGELOG.md +45 -1
- package/docs/CONFIGURATION.md +1 -1
- package/docs/SDK.md +1 -1
- package/package.json +1 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +44 -3
- package/src/config.ts +26 -9
- package/src/constants.ts +23 -4
- package/src/core/llm/research-model-resolver.ts +38 -0
- package/src/core/planning-service.ts +12 -1
- package/src/index.ts +3 -1
- package/src/infrastructure/browser/thread-worker-messaging.ts +5 -1
- package/src/infrastructure/browser/thread-worker.mjs +1 -1
- package/src/infrastructure/embedding/embedding-server.ts +29 -9
- package/src/knowledge/store.ts +60 -4
- package/src/observers/research-observer-impl.ts +14 -6
- package/src/orchestration/quick-research-orchestrator.ts +29 -10
- package/src/orchestration/research-synthesis-service.ts +32 -0
- package/src/orchestration/researcher-executor.ts +31 -16
- package/src/orchestration/researcher.ts +2 -2
- package/src/orchestration/session-state.ts +19 -4
- package/src/prompts/research-knowledge-relevance-triage.md +2 -0
- package/src/prompts/research-knowledge-search-extractor.md +2 -0
- package/src/prompts/researcher.md +2 -14
- package/src/sdk.ts +6 -0
- package/src/tools/index.ts +2 -1
- package/src/tools/research-knowledge-search.ts +37 -4
- package/src/tools/research-tool-definition.ts +27 -16
- package/src/tools/scrape.ts +8 -1
- package/src/utils/log-utils.ts +5 -2
- package/src/utils/metrics-summary.ts +21 -1
- package/src/utils/tool-usage-tracker.ts +6 -3
- package/src/web-research/web-scraper.ts +9 -0
- package/src/youtube/index.ts +11 -1
|
@@ -19,6 +19,7 @@ import { getConfig, type Config } from '../config.ts';
|
|
|
19
19
|
import { createResearcherSession } from './researcher.ts';
|
|
20
20
|
import { ensureAssistantResponse, parseCitations } from '../utils/text-utils.ts';
|
|
21
21
|
import { getMaxScrapeBatches, resolveExcludedTools } from '../constants.ts';
|
|
22
|
+
import { resolveResearchModel, isPromptCachingActiveForModel } from '../core/llm/research-model-resolver.ts';
|
|
22
23
|
import type { ResearchObserver } from '../core/interfaces/observer-interfaces.ts';
|
|
23
24
|
import { HeadlessObserver, makeSafeObserver, type HeadlessObserverOptions } from './headless-observer.ts';
|
|
24
25
|
import { getService, tryGetServiceContainerFromCtx } from '../core/service-registry.ts';
|
|
@@ -133,7 +134,15 @@ export class QuickResearchOrchestrator {
|
|
|
133
134
|
}
|
|
134
135
|
|
|
135
136
|
const researcherPromptTemplate = loadPrompt('researcher');
|
|
136
|
-
|
|
137
|
+
// Resolve the model here (same deterministic inputs createResearcherSession
|
|
138
|
+
// below will resolve again) purely to learn whether prompt caching will be
|
|
139
|
+
// active, so the batch count told to the model in the prompt below matches
|
|
140
|
+
// the limit ToolUsageTracker actually enforces — resolveResearchModel is a
|
|
141
|
+
// pure, cheap lookup over already-loaded registries, safe to call twice.
|
|
142
|
+
const cachingActiveForBudget = isPromptCachingActiveForModel(
|
|
143
|
+
resolveResearchModel({ modelRegistry: ctx.modelRegistry, config: this.config, hostModel: model, cwd: ctx.cwd })
|
|
144
|
+
);
|
|
145
|
+
const maxScrapeBatches = getMaxScrapeBatches(this.config, cachingActiveForBudget);
|
|
137
146
|
const maxScrapeBatchesDisplay = maxScrapeBatches > 99 ? 'unlimited' : maxScrapeBatches.toString();
|
|
138
147
|
|
|
139
148
|
const quickEvidenceSection =
|
|
@@ -162,19 +171,29 @@ export class QuickResearchOrchestrator {
|
|
|
162
171
|
}
|
|
163
172
|
evidenceLines.push(quickEvidenceSection);
|
|
164
173
|
|
|
174
|
+
// The system prompt carries only run-invariant instructions; the goal,
|
|
175
|
+
// knowledge-store results, evidence, and steering go in the initial USER
|
|
176
|
+
// message below. A byte-identical system prompt + tool set is the cacheable
|
|
177
|
+
// prefix providers key on, so back-to-back quick runs (and every follow-up
|
|
178
|
+
// turn within this session) reuse it via prompt-cache reads.
|
|
165
179
|
const prompt = injectCurrentDate(researcherPromptTemplate, 'researcher')
|
|
166
|
-
// Function replacers: insert `$`-bearing dynamic content (scraped evidence,
|
|
167
|
-
// store descriptions, the user query) literally, not as a substitution pattern.
|
|
168
|
-
.replace('{{goal}}', () => query + steeringSection)
|
|
169
|
-
.replace('{{store_section}}', () => storeSection)
|
|
170
|
-
.replace('{{evidence_section}}', () => evidenceLines.join('\n\n'))
|
|
171
|
-
.replace('{{coordination_section}}', '')
|
|
172
180
|
.replace('{{extra_tool_guidelines}}', '- `search`: Perform broad web searches (Round 1 only).')
|
|
173
181
|
// Quick research has no research lead and no next round — see
|
|
174
182
|
// RESEARCHER_DIGEST_SECTION. Its report IS the deliverable.
|
|
175
|
-
.replace('{{digest_section}}', '')
|
|
183
|
+
.replace('{{digest_section}}', '')
|
|
184
|
+
.trim();
|
|
176
185
|
|
|
177
|
-
|
|
186
|
+
// `$`-bearing dynamic content (the user query, store descriptions, steering)
|
|
187
|
+
// is plain string concatenation here — no String.replace substitution risk.
|
|
188
|
+
const userMessage = [
|
|
189
|
+
`Goal: ${query}`,
|
|
190
|
+
storeSection.trim(),
|
|
191
|
+
evidenceLines.join('\n\n').trim(),
|
|
192
|
+
steeringSection.trim(),
|
|
193
|
+
'Perform your research and submit your full report now.',
|
|
194
|
+
].filter(Boolean).join('\n\n');
|
|
195
|
+
|
|
196
|
+
logger.debug(`[QuickOrchestrator] System Prompt:\n${prompt}\n\nUser Message:\n${userMessage}`);
|
|
178
197
|
|
|
179
198
|
let lastSeenSearchCount = 0;
|
|
180
199
|
const { session, resolvedModel } = await createResearcherSession({
|
|
@@ -287,7 +306,7 @@ export class QuickResearchOrchestrator {
|
|
|
287
306
|
// whether the race resolves via session.prompt, timeout, or abort.
|
|
288
307
|
let abortCleanup: (() => void) | undefined;
|
|
289
308
|
try {
|
|
290
|
-
const promptPromise = session.prompt(
|
|
309
|
+
const promptPromise = session.prompt(userMessage);
|
|
291
310
|
promptPromise.catch((err: Error) => logger.debug(`[QuickOrchestrator] Background session prompt rejection: ${err.message}`));
|
|
292
311
|
await Promise.race([
|
|
293
312
|
promptPromise,
|
|
@@ -34,6 +34,22 @@ const NO_SOURCES_NOTE =
|
|
|
34
34
|
const UNVERIFIED_LINKS_NOTE =
|
|
35
35
|
'_None of the links above could be verified: no page fetch succeeded this session and no citation could be matched to retrieved content. They may be inaccurate or invented — check each one before relying on it._';
|
|
36
36
|
|
|
37
|
+
// Below this many verified sources, a completed report is WEAKLY grounded: it will
|
|
38
|
+
// still read as a fully sourced document, but almost none of its claims can trace to
|
|
39
|
+
// retrieved content — exactly the shape of a run where scrapes mostly failed (bot
|
|
40
|
+
// blocks, resource starvation) or the topic had little real coverage, and the model
|
|
41
|
+
// filled the gaps from its own weights. Observed in production: a depth-1 run whose
|
|
42
|
+
// report cited ONE page shipped with no caveat and fabricated most of its specifics;
|
|
43
|
+
// the calling agent had to detect the degradation itself. The note gives readers
|
|
44
|
+
// (human or agent) the machine-visible signal that was missing.
|
|
45
|
+
const WEAK_GROUNDING_MIN_SOURCES = 3;
|
|
46
|
+
|
|
47
|
+
function weakGroundingNote(count: number): string {
|
|
48
|
+
return `_Grounding notice: only ${count} source${count === 1 ? '' : 's'} could be verified for this report. ` +
|
|
49
|
+
'Claims not attributable to the cited source' + (count === 1 ? '' : 's') +
|
|
50
|
+
' above should be treated as unverified — cross-check anything load-bearing before relying on it._';
|
|
51
|
+
}
|
|
52
|
+
|
|
37
53
|
|
|
38
54
|
/**
|
|
39
55
|
* Redact inline `https?://` tokens in synthesis prose whose URL was NOT retrieved
|
|
@@ -382,6 +398,16 @@ export class ResearchSynthesisService implements IService {
|
|
|
382
398
|
// fabricated source can still reach the user; inline URLs also violate the
|
|
383
399
|
// required format. Verified inline references (in `trustedUrls`) are kept.
|
|
384
400
|
body = redactUnverifiedProseUrls(body, trustedUrls);
|
|
401
|
+
if (globalCitations.length < WEAK_GROUNDING_MIN_SOURCES) {
|
|
402
|
+
// The notice goes BEFORE the CITED LINKS header, never after it: anything
|
|
403
|
+
// trailing the last citation entry is absorbed into that entry's
|
|
404
|
+
// Description by parseCitations, and quick mode re-parses this exact
|
|
405
|
+
// string to build knowledge-store entries — a notice placed after the
|
|
406
|
+
// list polluted the stored description of the final source on precisely
|
|
407
|
+
// the degraded runs the notice exists to flag.
|
|
408
|
+
logger.warn(`[ResearchSynthesisService] Report is weakly grounded: only ${globalCitations.length} verified source(s) — appending grounding notice.`);
|
|
409
|
+
return `${body}\n\n${weakGroundingNote(globalCitations.length)}\n\n${verifiedLinksSection}`;
|
|
410
|
+
}
|
|
385
411
|
return `${body}\n\n${verifiedLinksSection}`;
|
|
386
412
|
}
|
|
387
413
|
logger.warn('[ResearchSynthesisService] Synthesis missing CITED LINKS - appending verified version');
|
|
@@ -393,6 +419,12 @@ export class ResearchSynthesisService implements IService {
|
|
|
393
419
|
const validIds = new Set(globalCitations.map((c) => c.id));
|
|
394
420
|
let prose = rewriteCitationMarkers(synthesis.trim(), new Map(), (n) => !validIds.has(n));
|
|
395
421
|
prose = redactUnverifiedProseUrls(prose, trustedUrls);
|
|
422
|
+
if (globalCitations.length < WEAK_GROUNDING_MIN_SOURCES) {
|
|
423
|
+
// Before the CITED LINKS header for the same parseCitations-absorption
|
|
424
|
+
// reason as the replace path above.
|
|
425
|
+
logger.warn(`[ResearchSynthesisService] Report is weakly grounded: only ${globalCitations.length} verified source(s) — appending grounding notice.`);
|
|
426
|
+
return `${prose}\n\n${weakGroundingNote(globalCitations.length)}\n\n${verifiedLinksSection}`;
|
|
427
|
+
}
|
|
396
428
|
return `${prose}\n\n${verifiedLinksSection}`;
|
|
397
429
|
}
|
|
398
430
|
|
|
@@ -180,6 +180,20 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
|
|
|
180
180
|
const researcherExecutionStartMs = Date.now();
|
|
181
181
|
const deliveredSteeringIds = new Set<string>();
|
|
182
182
|
|
|
183
|
+
// The system prompt is deliberately invariant across researchers, rounds, AND
|
|
184
|
+
// retry attempts: everything per-researcher (goal, knowledge-store results,
|
|
185
|
+
// initial evidence, sibling coordination, steering) is delivered in the initial
|
|
186
|
+
// USER message instead. A byte-identical system prompt + tool set is the
|
|
187
|
+
// cacheable prefix providers key on, so every researcher after the first gets a
|
|
188
|
+
// prompt-cache read on that prefix rather than a fresh write. Built once, out
|
|
189
|
+
// of the retry loop, because nothing in it varies per attempt.
|
|
190
|
+
const researcherSystemPrompt = injectCurrentDate(researcherPromptTemplate, 'researcher')
|
|
191
|
+
.replace('{{extra_tool_guidelines}}', '')
|
|
192
|
+
// Function replacer so `$`-bearing content is inserted literally, not as a
|
|
193
|
+
// String.replace substitution pattern.
|
|
194
|
+
.replace('{{digest_section}}', () => RESEARCHER_DIGEST_SECTION)
|
|
195
|
+
.trim();
|
|
196
|
+
|
|
183
197
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
184
198
|
// Launch boundary: never build a session for a run that is already cancelled
|
|
185
199
|
// or fast-stopped. The abort sentinel keeps the wrapper's clean-cancel
|
|
@@ -221,30 +235,31 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
|
|
|
221
235
|
}
|
|
222
236
|
}
|
|
223
237
|
|
|
224
|
-
//
|
|
238
|
+
// Build this attempt's initial USER message: the researcher's assignment plus
|
|
239
|
+
// every per-researcher section that used to live in the system prompt (goal,
|
|
240
|
+
// knowledge-store results, initial evidence, sibling coordination), and ALL
|
|
241
|
+
// steering messages delivered so far. Rebuilt per attempt so a retry picks up
|
|
242
|
+
// steering that arrived between attempts; the system prompt above never changes.
|
|
225
243
|
const allSteering = getSteeringMessages(sessionId);
|
|
226
244
|
let steeringSection = '';
|
|
227
245
|
if (allSteering.length > 0) {
|
|
228
|
-
steeringSection = '
|
|
246
|
+
steeringSection = '### ADDITIONAL USER GUIDANCE (Mandatory directional rules for your research)\n' +
|
|
229
247
|
allSteering.map(m => {
|
|
230
248
|
deliveredSteeringIds.add(m.id);
|
|
231
249
|
return `- ${m.text}`;
|
|
232
250
|
}).join('\n');
|
|
233
251
|
}
|
|
234
252
|
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
.
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
.replace('{{extra_tool_guidelines}}', '')
|
|
244
|
-
.replace('{{digest_section}}', () => RESEARCHER_DIGEST_SECTION)
|
|
245
|
-
.trim() + steeringSection;
|
|
253
|
+
const userMessage = [
|
|
254
|
+
`Topic: ${researcherConfig.name}\nGoal: ${researcherConfig.goal}`,
|
|
255
|
+
storeSection.trim(),
|
|
256
|
+
evidenceSection.trim(),
|
|
257
|
+
previousQueriesSection.trim(),
|
|
258
|
+
steeringSection,
|
|
259
|
+
'Perform your research and submit your full report now.',
|
|
260
|
+
].filter(Boolean).join('\n\n');
|
|
246
261
|
|
|
247
|
-
logger.debug(`[ResearcherExecutor] Researcher ${id} attempt ${attempt} System Prompt:\n${
|
|
262
|
+
logger.debug(`[ResearcherExecutor] Researcher ${id} attempt ${attempt} System Prompt:\n${researcherSystemPrompt}\n\nUser Message:\n${userMessage}`);
|
|
248
263
|
|
|
249
264
|
const workerExclude = ['search'];
|
|
250
265
|
const mergedExclude = [...new Set([...workerExclude, ...(options.excludeTools || [])])];
|
|
@@ -275,7 +290,7 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
|
|
|
275
290
|
cwd: ctx.cwd,
|
|
276
291
|
ctxModel: model,
|
|
277
292
|
modelRegistry: ctx.modelRegistry,
|
|
278
|
-
systemPrompt:
|
|
293
|
+
systemPrompt: researcherSystemPrompt,
|
|
279
294
|
extensionCtx: ctx,
|
|
280
295
|
excludeTools: mergedExclude,
|
|
281
296
|
researcherId: id,
|
|
@@ -434,7 +449,7 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
|
|
|
434
449
|
|
|
435
450
|
let abortCleanup: (() => void) | undefined;
|
|
436
451
|
try {
|
|
437
|
-
const promptPromise = session.prompt(
|
|
452
|
+
const promptPromise = session.prompt(userMessage);
|
|
438
453
|
promptPromise.catch((err: Error) => logger.debug(`[ResearcherExecutor] Background session prompt rejection: ${err.message}`));
|
|
439
454
|
await Promise.race([
|
|
440
455
|
promptPromise,
|
|
@@ -21,7 +21,7 @@ import type { SystemResearchState } from './deep-research-types.ts';
|
|
|
21
21
|
import type { Config } from '../config.ts';
|
|
22
22
|
import { logger } from '../logger.ts';
|
|
23
23
|
|
|
24
|
-
import { resolveResearchModel } from '../core/llm/research-model-resolver.ts';
|
|
24
|
+
import { resolveResearchModel, isPromptCachingActiveForModel } from '../core/llm/research-model-resolver.ts';
|
|
25
25
|
import { getRuntimeForRegistry } from '../core/llm/model-registry-factory.ts';
|
|
26
26
|
import { boundSessionAbort } from './abort-utils.ts';
|
|
27
27
|
|
|
@@ -86,7 +86,7 @@ export async function createResearcherSession(options: CreateResearcherSessionOp
|
|
|
86
86
|
logger.info(`[Researcher] Using model for researcher ${researcherId}: ${modelToUse.provider}/${modelToUse.id}`);
|
|
87
87
|
|
|
88
88
|
try {
|
|
89
|
-
const tracker = new ToolUsageTracker(createDefaultToolLimits(config));
|
|
89
|
+
const tracker = new ToolUsageTracker(createDefaultToolLimits(config, isPromptCachingActiveForModel(modelToUse)));
|
|
90
90
|
|
|
91
91
|
// Use provided closures or fallback to safe dummies
|
|
92
92
|
const globalLinks = updateGlobalLinks || (() => {});
|
|
@@ -518,8 +518,22 @@ export function endResearchSession(piSessionId: string | undefined, researchId:
|
|
|
518
518
|
|
|
519
519
|
// If this was the last research run in the Pi session, clean up the state
|
|
520
520
|
// but preserve steering messages if any remain (they might arrive between
|
|
521
|
-
// the last research end and the next research start)
|
|
522
|
-
|
|
521
|
+
// the last research end and the next research start).
|
|
522
|
+
//
|
|
523
|
+
// `state.aborts` is included alongside `order`/`panels`: those two are
|
|
524
|
+
// populated ONLY by the TUI path (registerSessionPanel), so for a purely
|
|
525
|
+
// headless multi-run session they are always empty — including while
|
|
526
|
+
// OTHER headless runs are still genuinely active. Gating on order/panels
|
|
527
|
+
// alone made this block treat every single headless run's end as "the
|
|
528
|
+
// last one," which both cleared `aborts` for still-running siblings
|
|
529
|
+
// (corrupting getActiveResearchRunCount for any caller relying on it) and,
|
|
530
|
+
// combined with a caller that clears steering right after seeing count 0,
|
|
531
|
+
// could wipe a message this very block had just decided to preserve one
|
|
532
|
+
// statement earlier. `aborts.delete(researchId)` above already removed
|
|
533
|
+
// this run's own entry, so `aborts.size === 0` here means no run of any
|
|
534
|
+
// kind (TUI or headless) remains — the same atomic, single-synchronous-
|
|
535
|
+
// function guarantee `order`/`panels` already had for the TUI-only case.
|
|
536
|
+
if (state.order.length === 0 && state.panels.size === 0 && state.aborts.size === 0) {
|
|
523
537
|
// Drop steering messages this run already consumed ('active') — and any
|
|
524
538
|
// 'popped' remnants, which are likewise already resolved — before deciding
|
|
525
539
|
// whether to keep the session alive. Without this, a message the run that
|
|
@@ -540,10 +554,11 @@ export function endResearchSession(piSessionId: string | undefined, researchId:
|
|
|
540
554
|
clearPendingRefresh(sid);
|
|
541
555
|
piSessions.delete(sid);
|
|
542
556
|
} else {
|
|
543
|
-
// Clear research-specific state but preserve the session and steering
|
|
557
|
+
// Clear research-specific state but preserve the session and steering.
|
|
558
|
+
// aborts is already empty (the outer gate above requires it), so no
|
|
559
|
+
// separate clear is needed for it here.
|
|
544
560
|
state.failures.clear();
|
|
545
561
|
state.failureReasons.clear();
|
|
546
|
-
state.aborts.clear();
|
|
547
562
|
}
|
|
548
563
|
}
|
|
549
564
|
}
|
|
@@ -18,6 +18,8 @@ Your ONLY job is to decide which candidates are genuinely relevant to the user's
|
|
|
18
18
|
Field definitions:
|
|
19
19
|
- `relevant_indices` (array of integers, required): the 0-based indices of the relevant candidates, in any order. Empty array `[]` if none are relevant.
|
|
20
20
|
|
|
21
|
+
<!-- USER_TURN: everything below is delivered as the USER message of the stateless call, not the system prompt. The rules above stay byte-identical across calls so the provider can serve the system prefix from its prompt cache; the sections below vary per call. -->
|
|
22
|
+
|
|
21
23
|
## USER'S SEARCH QUERY
|
|
22
24
|
|
|
23
25
|
Judge each candidate's relevance to this search query. This is the PRIMARY signal for what the user wants; the conversation below is supplementary and may be absent for a direct (non-conversational) search:
|
|
@@ -24,6 +24,8 @@ Field definitions:
|
|
|
24
24
|
- `synthesis` (string, optional): The synthesized answer if found. Use inline citation markers [1], [2], etc. that correspond to the citations array. Present when answer_status is "yes" or "maybe". Omit when answer_status is "no".
|
|
25
25
|
- `citations` (array of strings, required): The source URLs from the reference documents that were used to construct the answer. Empty array if answer_status is "no".
|
|
26
26
|
|
|
27
|
+
<!-- USER_TURN: everything below is delivered as the USER message of the stateless call, not the system prompt. The rules above stay byte-identical across calls so the provider can serve the system prefix from its prompt cache; the sections below vary per call. -->
|
|
28
|
+
|
|
27
29
|
## USER'S SEARCH QUERY
|
|
28
30
|
|
|
29
31
|
Answer this search query from the reference documents. This is the PRIMARY signal for what the user wants; the conversation below is supplementary and may be absent for a direct (non-conversational) search:
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- RESEARCHER_AGENT_MARKER -->
|
|
4
4
|
|
|
5
|
-
You are an autonomous research agent. Your goal is to investigate your assigned topic with depth and rigor.
|
|
5
|
+
You are an autonomous research agent. Your goal is to investigate your assigned topic with depth and rigor. Your specific goal, any historical knowledge-store results, your initial evidence/search results, and any sibling-coordination notes are provided in the user message that follows this system prompt — not here, so this system prompt stays byte-identical across every researcher and round (prompt caching).
|
|
6
6
|
|
|
7
7
|
## CORE DIRECTIVES (Strict Enforcement)
|
|
8
8
|
|
|
@@ -23,20 +23,10 @@ Do not treat any source as inherently objective. Justify its inclusion through t
|
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
-
## Your Goal
|
|
27
|
-
{{goal}}
|
|
28
|
-
|
|
29
|
-
{{store_section}}
|
|
30
|
-
(Note: when knowledge-store results appear above, they were retrieved automatically for your goal, aggregated from the **Project Knowledge Store** and **User Knowledge Store**. Do NOT try to search the store manually.)
|
|
31
|
-
|
|
32
|
-
{{evidence_section}}
|
|
33
|
-
|
|
34
|
-
---
|
|
35
|
-
|
|
36
26
|
## Workflow
|
|
37
27
|
|
|
38
28
|
### Step 1: Build Your Source List
|
|
39
|
-
Combine historical URLs and search results into a unified pool. Use previous session summaries as a guide for what to expect.
|
|
29
|
+
Combine historical URLs and search results into a unified pool. Use previous session summaries as a guide for what to expect. Any knowledge-store results in your user message were retrieved automatically for your goal from the **Project Knowledge Store** and/or **User Knowledge Store** — cite them with that store name as their `Source:`.
|
|
40
30
|
|
|
41
31
|
### Step 2: Scrape Round 1
|
|
42
32
|
Identify the 4 most promising URLs and scrape them in one batch (the scrape tool accepts up to 6 per call; 4 is your target per round). Prioritize primary sources, authoritative references, and dense documentation; all else being equal, lean toward more recent / current sources and topics.
|
|
@@ -51,8 +41,6 @@ Do NOT scrape URLs directly from the pool — only scrape URLs from your own sou
|
|
|
51
41
|
### Step 4: Synthesize
|
|
52
42
|
Write your report immediately after scraping is complete, or as soon as a tool reports its limit is reached (e.g. "GATHERING LIMIT REACHED", "SEARCH LIMIT REACHED", "SCRAPE PROTOCOL COMPLETE"). Make no further tool calls after beginning synthesis.
|
|
53
43
|
|
|
54
|
-
{{coordination_section}}
|
|
55
|
-
|
|
56
44
|
---
|
|
57
45
|
|
|
58
46
|
## Guidelines
|
package/src/sdk.ts
CHANGED
|
@@ -111,6 +111,7 @@ let _onSignal: ((signal: string) => void) | null = null;
|
|
|
111
111
|
let _sigintHandler: (() => void) | null = null;
|
|
112
112
|
let _sigtermHandler: (() => void) | null = null;
|
|
113
113
|
let _sighupHandler: (() => void) | null = null;
|
|
114
|
+
let _sigquitHandler: (() => void) | null = null;
|
|
114
115
|
let _sigbreakHandler: (() => void) | null = null;
|
|
115
116
|
let _shuttingDown = false;
|
|
116
117
|
// In-flight shutdown guard. A cooperative shutdownResearchSDK() can race a
|
|
@@ -147,9 +148,13 @@ function _registerSignalHandlers(): void {
|
|
|
147
148
|
_sigintHandler = () => _onSignal?.('SIGINT');
|
|
148
149
|
_sigtermHandler = () => _onSignal?.('SIGTERM');
|
|
149
150
|
_sighupHandler = () => _onSignal?.('SIGHUP');
|
|
151
|
+
_sigquitHandler = () => _onSignal?.('SIGQUIT');
|
|
150
152
|
process.on('SIGINT', _sigintHandler);
|
|
151
153
|
process.on('SIGTERM', _sigtermHandler);
|
|
152
154
|
process.on('SIGHUP', _sighupHandler);
|
|
155
|
+
// SIGQUIT has no Windows equivalent; registering the listener there is a
|
|
156
|
+
// documented no-op (Node never delivers it), so no platform guard is needed.
|
|
157
|
+
process.on('SIGQUIT', _sigquitHandler);
|
|
153
158
|
if (process.platform === 'win32') {
|
|
154
159
|
_sigbreakHandler = () => _onSignal?.('SIGBREAK');
|
|
155
160
|
process.on('SIGBREAK', _sigbreakHandler);
|
|
@@ -160,6 +165,7 @@ function _removeSignalHandlers(): void {
|
|
|
160
165
|
if (_sigintHandler) { process.removeListener('SIGINT', _sigintHandler); _sigintHandler = null; }
|
|
161
166
|
if (_sigtermHandler) { process.removeListener('SIGTERM', _sigtermHandler); _sigtermHandler = null; }
|
|
162
167
|
if (_sighupHandler) { process.removeListener('SIGHUP', _sighupHandler); _sighupHandler = null; }
|
|
168
|
+
if (_sigquitHandler) { process.removeListener('SIGQUIT', _sigquitHandler); _sigquitHandler = null; }
|
|
163
169
|
if (_sigbreakHandler) { process.removeListener('SIGBREAK', _sigbreakHandler); _sigbreakHandler = null; }
|
|
164
170
|
_onSignal = null;
|
|
165
171
|
}
|
package/src/tools/index.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*
|
|
7
7
|
* NOTE: `research_knowledge_search` is NOT included here. It is a top-level tool
|
|
8
8
|
* registered via pi.registerTool() in src/index.ts. Researcher sub-agents
|
|
9
|
-
* already receive knowledge
|
|
9
|
+
* already receive knowledge-store data in their initial user message (see
|
|
10
|
+
* researcher-executor.ts / quick-research-orchestrator.ts).
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import type { ToolDefinition, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
@@ -427,6 +427,27 @@ export function isTransientSynthesisError(message: string): boolean {
|
|
|
427
427
|
);
|
|
428
428
|
}
|
|
429
429
|
|
|
430
|
+
/**
|
|
431
|
+
* Split a knowledge-search prompt template at its USER_TURN marker into the
|
|
432
|
+
* static system-prompt part (the rules, byte-identical across calls so the
|
|
433
|
+
* provider can serve it from its prompt cache) and the per-call user-message
|
|
434
|
+
* part (where the {{...}} placeholders live). If the marker is absent —
|
|
435
|
+
* a shipped-file regression — returns the whole template as `system` and
|
|
436
|
+
* `user: null`, and the caller falls back to the legacy all-in-system shape,
|
|
437
|
+
* which is functionally correct (just uncacheable).
|
|
438
|
+
* Exported for unit testing.
|
|
439
|
+
*/
|
|
440
|
+
export function splitPromptAtUserTurn(template: string): { system: string; user: string | null } {
|
|
441
|
+
const open = template.indexOf('<!-- USER_TURN');
|
|
442
|
+
if (open < 0) return { system: template, user: null };
|
|
443
|
+
const close = template.indexOf('-->', open);
|
|
444
|
+
if (close < 0) return { system: template, user: null };
|
|
445
|
+
return {
|
|
446
|
+
system: template.slice(0, open).trimEnd(),
|
|
447
|
+
user: template.slice(close + '-->'.length).trimStart(),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
430
451
|
/** Exported for unit testing of the transient-retry loop. */
|
|
431
452
|
export async function runBackgroundExtraction(
|
|
432
453
|
model: Model<any>,
|
|
@@ -452,13 +473,20 @@ export async function runBackgroundExtraction(
|
|
|
452
473
|
// plain string replacement interprets `$&`, `` $` ``, `$'`, `$$` as substitution patterns and
|
|
453
474
|
// corrupts the extractor prompt (duplicating/eating template text) → wrong yes/maybe/no
|
|
454
475
|
// classification. A function replacer inserts the value literally. (Same class as commit 78c16f98.)
|
|
455
|
-
|
|
476
|
+
//
|
|
477
|
+
// The template splits at USER_TURN: the static rules become the system prompt
|
|
478
|
+
// (byte-identical across calls → prompt-cache reads instead of a fresh cache
|
|
479
|
+
// write per call) and the per-call sections go in the user message.
|
|
480
|
+
const { system, user } = splitPromptAtUserTurn(promptTemplate);
|
|
481
|
+
const fill = (t: string): string => t
|
|
456
482
|
.replace('{{queries}}', () => formatQueriesBlock(queries))
|
|
457
483
|
.replace('{{conversation_history}}', () => conversationHistory)
|
|
458
484
|
.replace('{{reference_documents}}', () => referenceDocuments);
|
|
459
485
|
|
|
460
|
-
const
|
|
486
|
+
const finalInstruction =
|
|
461
487
|
'Analyze the reference documents above and extract the answer using the required JSON format.';
|
|
488
|
+
const systemPrompt = user === null ? fill(system) : system;
|
|
489
|
+
const userMessage = user === null ? finalInstruction : `${fill(user)}\n\n${finalInstruction}`;
|
|
462
490
|
|
|
463
491
|
// Phase 4a: Stateless LLM call — no AgentSession, no side-effects.
|
|
464
492
|
// llmTimeout is resolved by the caller from the iface-aware Config so a
|
|
@@ -611,12 +639,17 @@ export async function triageRelevantUrls(
|
|
|
611
639
|
|
|
612
640
|
// FUNCTION replacers so literal `$`/`$&` in queries, descriptions or history are not
|
|
613
641
|
// treated as regex substitution patterns (same class as the synthesis prompt).
|
|
614
|
-
|
|
642
|
+
// Split at USER_TURN like the extractor: static rules stay in the system prompt
|
|
643
|
+
// (cacheable across calls), per-call sections go in the user message.
|
|
644
|
+
const { system, user } = splitPromptAtUserTurn(promptTemplate);
|
|
645
|
+
const fill = (t: string): string => t
|
|
615
646
|
.replace('{{queries}}', () => formatQueriesBlock(queries))
|
|
616
647
|
.replace('{{conversation_history}}', () => conversationHistory)
|
|
617
648
|
.replace('{{candidates}}', () => candidateBlock);
|
|
618
649
|
|
|
619
|
-
const
|
|
650
|
+
const finalInstruction = 'Return the JSON object listing the indices of the relevant candidates.';
|
|
651
|
+
const systemPrompt = user === null ? fill(system) : system;
|
|
652
|
+
const userMessage = user === null ? finalInstruction : `${fill(user)}\n\n${finalInstruction}`;
|
|
620
653
|
|
|
621
654
|
let responseText: string;
|
|
622
655
|
let attempt = 0;
|
|
@@ -29,11 +29,12 @@ import { redactSecrets } from '../utils/log-utils.ts';
|
|
|
29
29
|
import { exportResearchReport, appendExportMessage } from '../utils/research-export.ts';
|
|
30
30
|
import { validateAndSanitizeQuery } from '../utils/input-validation.ts';
|
|
31
31
|
import { validateInitialLinks, MAX_INITIAL_LINKS, MAX_INITIAL_LINK_CHARS } from '../utils/url-utils.ts';
|
|
32
|
-
import { startResearchSession, endResearchSession, registerSessionAbort
|
|
32
|
+
import { startResearchSession, endResearchSession, registerSessionAbort } from '../orchestration/session-state.ts';
|
|
33
33
|
import { createResearchTuiManager, hideWorkingIndicator } from '../tui/research-tui-manager.ts';
|
|
34
34
|
import { createCleanupFunction } from '../cleanup/research-cleanup.ts';
|
|
35
35
|
import { createResearchObserver, createObserverState, stopObserverWaveAnimation } from '../observers/research-observer-impl.ts';
|
|
36
36
|
import { HeadlessObserver } from '../orchestration/headless-observer.ts';
|
|
37
|
+
import { isPromptCachingActiveForModel } from '../core/llm/research-model-resolver.ts';
|
|
37
38
|
|
|
38
39
|
import { ensureFunctionalHealth, createHealthMonitor } from '../tui/research-health.ts';
|
|
39
40
|
import { ErrorTracker, runWithTracker, type ErrorReport } from '../utils/error-tracker.ts';
|
|
@@ -252,19 +253,21 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
|
|
|
252
253
|
if (observerState && panelState) {
|
|
253
254
|
try { stopObserverWaveAnimation(observerState, panelState); } catch (e) { logger.error('[research] Stop wave animation failed:', e); }
|
|
254
255
|
}
|
|
255
|
-
// Steering messages are shared per pi-session. Only reset them when THIS is
|
|
256
|
-
// the last active run — otherwise a finishing run wipes a concurrent sibling
|
|
257
|
-
// run's queued/active steering mid-flight. This run's abort registration is
|
|
258
|
-
// still in place here (endResearchSession runs later in cleanup()), so a
|
|
259
|
-
// count of <= 1 means no other run is active. getActiveResearchRunCount
|
|
260
|
-
// (not getPiActivePanels) because headless runs never register a panel —
|
|
261
|
-
// a panel-based check here was always a no-op for headless callers.
|
|
262
|
-
try {
|
|
263
|
-
if (getActiveResearchRunCount(piSessionId) <= 1) clearSteeringMessages(piSessionId);
|
|
264
|
-
} catch (e) { logger.error('[research] Clear steering messages failed:', e); }
|
|
265
256
|
if (detachAbortListener) {
|
|
266
257
|
try { detachAbortListener(); } catch { /* best-effort */ } finally { detachAbortListener = undefined; }
|
|
267
258
|
}
|
|
259
|
+
// cleanup() calls endResearchSession (directly in the headless closure,
|
|
260
|
+
// or via createCleanupFunction for TUI), which deregisters this run and,
|
|
261
|
+
// in the same synchronous call, decides whether it was the last active
|
|
262
|
+
// run (TUI or headless) in the pi-session and — only then — resets
|
|
263
|
+
// steering messages (preserving any still-genuinely-queued ones for the
|
|
264
|
+
// next run, dropping only ones this run already consumed). That single
|
|
265
|
+
// synchronous function is the right place for this decision: a separate
|
|
266
|
+
// check-then-clear here, even ordered after cleanup(), raced ahead of or
|
|
267
|
+
// fought with endResearchSession's own last-run/preserve logic — either
|
|
268
|
+
// reading stale counts before two racing siblings had both deregistered,
|
|
269
|
+
// or unconditionally wiping a message endResearchSession had just decided
|
|
270
|
+
// to preserve. See endResearchSession in session-state.ts.
|
|
268
271
|
if (cleanup) {
|
|
269
272
|
try { await cleanup(); } catch (cleanupError) { logger.error('[research] Cleanup failed:', cleanupError); }
|
|
270
273
|
}
|
|
@@ -357,7 +360,16 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
|
|
|
357
360
|
// Setup TUI observer
|
|
358
361
|
observerState = createObserverState();
|
|
359
362
|
observer = createResearchObserver(
|
|
360
|
-
{
|
|
363
|
+
{
|
|
364
|
+
panelState,
|
|
365
|
+
debouncedRefresh: () => tuiManager?.debouncedRefresh(),
|
|
366
|
+
renderImmediate: () => tuiManager?.renderImmediate(),
|
|
367
|
+
researchComplexity: depth ?? 1,
|
|
368
|
+
// Progress units are relative to the EFFECTIVE scrape-batch count,
|
|
369
|
+
// which is one higher when prompt caching is active for the run's
|
|
370
|
+
// resolved model — same value the researchers' trackers enforce.
|
|
371
|
+
cachingActive: isPromptCachingActiveForModel(selectedModel),
|
|
372
|
+
},
|
|
361
373
|
observerState
|
|
362
374
|
);
|
|
363
375
|
} else {
|
|
@@ -373,10 +385,9 @@ export function createResearchTool(iface?: ConfigInterface): ToolDefinition {
|
|
|
373
385
|
// module-level piSessions map keyed by the STABLE pi session id, so
|
|
374
386
|
// without this every headless run leaked them for the process
|
|
375
387
|
// lifetime (the TUI branch frees them via createCleanupFunction, the
|
|
376
|
-
// SDK in its own finally).
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
// unconditional clear here bypassed that guard entirely.
|
|
388
|
+
// SDK in its own finally). endResearchSession itself decides whether
|
|
389
|
+
// this was the last active run (TUI or headless) and, only then,
|
|
390
|
+
// resets steering messages — see its own comment in session-state.ts.
|
|
380
391
|
endResearchSession(piSessionId, sessionResearchId);
|
|
381
392
|
};
|
|
382
393
|
|
package/src/tools/scrape.ts
CHANGED
|
@@ -49,7 +49,14 @@ export function createScrapeTool(options: {
|
|
|
49
49
|
config?: Config;
|
|
50
50
|
}): ToolDefinition {
|
|
51
51
|
const config = options.config || getConfig(options.ctx.cwd);
|
|
52
|
-
|
|
52
|
+
// Prefer the tracker's own scrape limit when one is present: it was built via
|
|
53
|
+
// createDefaultToolLimits(config, cachingActive) and so already reflects any
|
|
54
|
+
// prompt-caching batch bonus (see getMaxScrapeBatches in constants.ts). Without
|
|
55
|
+
// this, the tool's own description/promptSnippet/protocol text (below) told the
|
|
56
|
+
// model a lower, non-caching-aware batch count than the tracker actually
|
|
57
|
+
// enforced — a real, if harmless, missed-opportunity: the model would stop one
|
|
58
|
+
// batch short of what it was actually allowed to use.
|
|
59
|
+
const maxScrapeBatches = options.tracker?.getToolLimit('scrape') ?? getMaxScrapeBatches(config);
|
|
53
60
|
const container = tryGetServiceContainerFromCtx(options.ctx);
|
|
54
61
|
|
|
55
62
|
// Fallback global state when no orchestration context is provided
|
package/src/utils/log-utils.ts
CHANGED
|
@@ -37,8 +37,11 @@ export function buildDefaultDebugLogPath(): string {
|
|
|
37
37
|
/**
|
|
38
38
|
* Check whether debug/verbose logging is enabled.
|
|
39
39
|
*
|
|
40
|
-
* Reads PI_RESEARCH_DEBUG env var.
|
|
41
|
-
* config.
|
|
40
|
+
* Reads PI_RESEARCH_DEBUG env var. Config SAVES sync config.DEBUG into this
|
|
41
|
+
* env var (see config.ts buildEnvValues); plain config LOADS do not mutate
|
|
42
|
+
* process.env, so a DEBUG=true line in config.env enables verbose logging for
|
|
43
|
+
* a process only after a save has run in it — export PI_RESEARCH_DEBUG=true
|
|
44
|
+
* in the environment to guarantee it from process start.
|
|
42
45
|
* Set to "true" to enable INFO+DEBUG logs.
|
|
43
46
|
*/
|
|
44
47
|
export function isVerboseFromEnv(): boolean {
|
|
@@ -48,6 +48,12 @@ export interface ResearchStats {
|
|
|
48
48
|
errors: number;
|
|
49
49
|
/** Total LLM tokens consumed. */
|
|
50
50
|
tokens: number;
|
|
51
|
+
/** Prompt-cache READ tokens (prompt prefix served from the provider's cache at the
|
|
52
|
+
* discounted rate). 0 when the provider reported none or does not report caching. */
|
|
53
|
+
cacheReadTokens: number;
|
|
54
|
+
/** Prompt-cache WRITE tokens (prefix stored into the provider's cache, billed at a
|
|
55
|
+
* premium). Reads pinned at 0 while writes climb = a caching regression. */
|
|
56
|
+
cacheWriteTokens: number;
|
|
51
57
|
/** Total cost in USD across all LLM calls. */
|
|
52
58
|
cost: number;
|
|
53
59
|
/** Tool usage counts. */
|
|
@@ -193,6 +199,12 @@ export function extractRunStats(snapshot: IMetricsSnapshot): ResearchStats | nul
|
|
|
193
199
|
// Tokens — sum all label variants
|
|
194
200
|
const tokens = sumCounter(counters, 'llm_tokens_total');
|
|
195
201
|
|
|
202
|
+
// Prompt-cache split (recordLlmUsage emits these only when the provider reports
|
|
203
|
+
// the fields, so 0 here covers both "cache off" and "not reported" — the raw
|
|
204
|
+
// counters keep those distinguishable for anyone reading the snapshot directly).
|
|
205
|
+
const cacheReadTokens = sumCounter(counters, 'llm_cache_read_tokens_total');
|
|
206
|
+
const cacheWriteTokens = sumCounter(counters, 'llm_cache_write_tokens_total');
|
|
207
|
+
|
|
196
208
|
// Cost — sum all label variants
|
|
197
209
|
const cost = sumCounter(counters, 'llm_cost_total');
|
|
198
210
|
|
|
@@ -251,6 +263,8 @@ export function extractRunStats(snapshot: IMetricsSnapshot): ResearchStats | nul
|
|
|
251
263
|
urlsFailed,
|
|
252
264
|
errors,
|
|
253
265
|
tokens,
|
|
266
|
+
cacheReadTokens,
|
|
267
|
+
cacheWriteTokens,
|
|
254
268
|
cost,
|
|
255
269
|
toolUsage,
|
|
256
270
|
};
|
|
@@ -315,7 +329,13 @@ export function buildResearchSummary(stats: ResearchStats): string {
|
|
|
315
329
|
// --- Resource line: tokens, cost, duration ---
|
|
316
330
|
const resourceParts: string[] = [];
|
|
317
331
|
if (stats.tokens > 0) {
|
|
318
|
-
|
|
332
|
+
// When prompt caching did real work, say so inline: cache reads are prompt
|
|
333
|
+
// tokens billed at the provider's discounted cached rate, and this is the
|
|
334
|
+
// only user-visible confirmation that caching is active for their model.
|
|
335
|
+
const cached = stats.cacheReadTokens > 0
|
|
336
|
+
? ` (**${formatTokens(stats.cacheReadTokens)}** from cache)`
|
|
337
|
+
: '';
|
|
338
|
+
resourceParts.push(`**${formatTokens(stats.tokens)}** tokens${cached}`);
|
|
319
339
|
}
|
|
320
340
|
// Render the cost whenever tokens were actually billed-through, INCLUDING when it
|
|
321
341
|
// is exactly zero. Gating on `cost > 0` hid a real defect for weeks: a model whose
|
|
@@ -195,12 +195,15 @@ export class ToolUsageTracker {
|
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
/**
|
|
198
|
-
* Create default tool limits for a researcher
|
|
198
|
+
* Create default tool limits for a researcher.
|
|
199
|
+
*
|
|
200
|
+
* @param cachingActive - Whether prompt caching is active for this run's
|
|
201
|
+
* resolved model; see getMaxScrapeBatches in constants.ts.
|
|
199
202
|
*/
|
|
200
|
-
export function createDefaultToolLimits(config?: Config): ToolLimits {
|
|
203
|
+
export function createDefaultToolLimits(config?: Config, cachingActive = false): ToolLimits {
|
|
201
204
|
return {
|
|
202
205
|
gathering: getMaxGatheringCalls(config),
|
|
203
|
-
scrape: getMaxScrapeBatches(config),
|
|
206
|
+
scrape: getMaxScrapeBatches(config, cachingActive),
|
|
204
207
|
search: 1,
|
|
205
208
|
youtube_transcript: 1,
|
|
206
209
|
grep: MAX_GREP_CALLS,
|