@lincoln504/pi-research 1.4.1 → 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 +449 -187
- 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 +91 -59
- package/docs/AGENT-SKILL.md +4 -3
- package/docs/CHANGELOG.md +120 -0
- package/docs/CONFIGURATION.md +2 -2
- 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 +36 -13
- package/src/constants.ts +23 -4
- package/src/core/llm/research-model-resolver.ts +38 -0
- package/src/core/planning-service.ts +67 -9
- package/src/index.ts +3 -1
- package/src/infrastructure/browser/browser-task-scheduler.ts +16 -9
- package/src/infrastructure/browser/config.ts +40 -2
- package/src/infrastructure/browser/thread-worker-browser.ts +69 -48
- package/src/infrastructure/browser/thread-worker-messaging.ts +65 -4
- package/src/infrastructure/browser/thread-worker.mjs +91 -59
- package/src/infrastructure/browser/thread-worker.ts +13 -5
- package/src/infrastructure/embedding/embedding-server.ts +54 -0
- package/src/knowledge/embedder-init.ts +3 -2
- package/src/knowledge/embedder.ts +11 -3
- package/src/knowledge/index.ts +36 -10
- package/src/knowledge/store.ts +85 -5
- package/src/observers/research-observer-impl.ts +14 -6
- package/src/orchestration/deep-research-orchestrator.ts +3 -1
- 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 +35 -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 +16 -0
- package/src/skill-install/skill-installer.ts +5 -1
- package/src/stackexchange/index.ts +3 -0
- package/src/stackexchange/rest-client.ts +7 -12
- package/src/tools/index.ts +2 -1
- package/src/tools/research-knowledge-search.ts +37 -4
- package/src/tools/research-tool-definition.ts +54 -12
- package/src/tools/scrape.ts +8 -1
- package/src/tools/security.ts +13 -10
- package/src/utils/coverage-digest.ts +1 -1
- package/src/utils/host-config.ts +12 -1
- package/src/utils/log-utils.ts +15 -2
- package/src/utils/metrics-summary.ts +21 -1
- package/src/utils/tool-usage-tracker.ts +6 -3
- package/src/web-research/browser-search.ts +11 -4
- package/src/web-research/web-scraper.ts +36 -16
- package/src/youtube/index.ts +11 -1
- package/src/youtube/potoken.ts +8 -1
- package/src/youtube/transcript-client.ts +56 -5
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
import {
|
|
24
24
|
setWorkerId,
|
|
25
25
|
initBrowser,
|
|
26
|
-
|
|
26
|
+
acquireTaskContext,
|
|
27
27
|
resetBrowser,
|
|
28
28
|
cleanupBrowser,
|
|
29
29
|
taskStarted,
|
|
@@ -107,23 +107,28 @@ async function runTask(data: TaskData | undefined): Promise<TaskResult> {
|
|
|
107
107
|
// Registered around the whole browser-touching lifetime of this task so
|
|
108
108
|
// resetBrowser() (below) can tell whether a sibling task dispatched to this
|
|
109
109
|
// same worker process (WORKER_CONCURRENCY > 1) is still using the shared
|
|
110
|
-
// browser
|
|
110
|
+
// browser before tearing it down.
|
|
111
111
|
taskStarted();
|
|
112
|
+
// Task-owned, not module-owned: acquireTaskContext() below hands back a
|
|
113
|
+
// FRESH BrowserContext for this task alone, closed in the finally below —
|
|
114
|
+
// never shared with a concurrent or later sibling task.
|
|
115
|
+
let taskContext: any;
|
|
112
116
|
try {
|
|
113
117
|
await initBrowser();
|
|
114
118
|
const initMs = Date.now() - startTime;
|
|
119
|
+
taskContext = await acquireTaskContext();
|
|
115
120
|
|
|
116
121
|
let result: any;
|
|
117
122
|
if (type === 'search') {
|
|
118
123
|
if (!query) throw new Error('Search task requires a query');
|
|
119
|
-
const searchResult = await executeSearchTask(
|
|
124
|
+
const searchResult = await executeSearchTask(taskContext, query, abortController.signal);
|
|
120
125
|
result = { results: searchResult.results, duration: Date.now() - startTime, jitter: searchResult.jitter };
|
|
121
126
|
} else if (type === 'scrape') {
|
|
122
127
|
if (!url) throw new Error('Scrape task requires a URL');
|
|
123
|
-
const scrapeResult = await executeScrapeTask(
|
|
128
|
+
const scrapeResult = await executeScrapeTask(taskContext, url, abortController.signal);
|
|
124
129
|
result = { ...scrapeResult, duration: Date.now() - startTime };
|
|
125
130
|
} else if (type === 'healthcheck') {
|
|
126
|
-
const healthResult = await executeHealthCheck(
|
|
131
|
+
const healthResult = await executeHealthCheck(taskContext, initMs, abortController.signal);
|
|
127
132
|
result = { ...healthResult, duration: Date.now() - startTime };
|
|
128
133
|
} else {
|
|
129
134
|
result = { error: 'Unknown task type', duration: Date.now() - startTime };
|
|
@@ -156,6 +161,9 @@ async function runTask(data: TaskData | undefined): Promise<TaskResult> {
|
|
|
156
161
|
};
|
|
157
162
|
} finally {
|
|
158
163
|
if (taskTimer !== undefined) clearTimeout(taskTimer);
|
|
164
|
+
if (taskContext) {
|
|
165
|
+
Promise.resolve(taskContext.close()).catch(() => { /* task is already done; a close failure here is not actionable */ });
|
|
166
|
+
}
|
|
159
167
|
taskFinished();
|
|
160
168
|
}
|
|
161
169
|
}
|
|
@@ -80,6 +80,11 @@ class SerialQueue {
|
|
|
80
80
|
private readonly hardDeadlineMs: number;
|
|
81
81
|
private readonly onPoison?: (err: Error) => void;
|
|
82
82
|
private poisoned: Error | null = null;
|
|
83
|
+
// The currently-executing task's real work promise (not the soft-timeout race) —
|
|
84
|
+
// set only while a native call is actually in flight. Lets a caller that needs
|
|
85
|
+
// to dispose the underlying pipeline (EmbeddingServer.shutdown) wait for true
|
|
86
|
+
// idleness instead of racing disposal against a still-running inference.
|
|
87
|
+
private currentWork: Promise<unknown> | null = null;
|
|
83
88
|
|
|
84
89
|
constructor(
|
|
85
90
|
maxDepth = 200,
|
|
@@ -132,6 +137,7 @@ class SerialQueue {
|
|
|
132
137
|
let hardTimer: ReturnType<typeof setTimeout> | undefined;
|
|
133
138
|
try {
|
|
134
139
|
work = fn();
|
|
140
|
+
this.currentWork = work;
|
|
135
141
|
// Arm the hard-deadline watchdog from the moment the real GPU work starts, so
|
|
136
142
|
// the cap is an honest wall-clock deadline measured from work-start rather than
|
|
137
143
|
// (soft-timeout + hardDeadlineMs). A single inference still unsettled after
|
|
@@ -162,6 +168,7 @@ class SerialQueue {
|
|
|
162
168
|
} finally {
|
|
163
169
|
settled = true;
|
|
164
170
|
if (hardTimer !== undefined) clearTimeout(hardTimer);
|
|
171
|
+
if (this.currentWork === work) this.currentWork = null;
|
|
165
172
|
}
|
|
166
173
|
}
|
|
167
174
|
};
|
|
@@ -170,6 +177,43 @@ class SerialQueue {
|
|
|
170
177
|
});
|
|
171
178
|
}
|
|
172
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Resolve once no native call is in flight AND no queued task is still due
|
|
182
|
+
* to run. Used by EmbeddingServer.shutdown() to wait out a still-running
|
|
183
|
+
* inference before disposing the pipeline — without this, disposal could
|
|
184
|
+
* run concurrently with a legitimately slow (not hung) embed/embedMany call
|
|
185
|
+
* on the same re-entrant-locked GPU context, the exact native-segfault
|
|
186
|
+
* class this queue exists to prevent. No timeout: a promise that truly
|
|
187
|
+
* never settles is already an accepted, pre-existing leak-to-process-exit
|
|
188
|
+
* scenario elsewhere in this class (see the poison path's
|
|
189
|
+
* disposeEmbedder=false handling) — better to leak than risk concurrent
|
|
190
|
+
* native access.
|
|
191
|
+
*
|
|
192
|
+
* Checking only `currentWork` is NOT enough with a second task already
|
|
193
|
+
* queued: a task's `run()` clears `currentWork` to null in its `finally`
|
|
194
|
+
* (once its native call has settled), and `pump()` only claims it again for
|
|
195
|
+
* the next task on a LATER microtask tick, once `run()` for the current
|
|
196
|
+
* task has fully unwound and `await entry.run()` resumes. If a caller's own
|
|
197
|
+
* check happens to land in that gap, `currentWork` reads as null even
|
|
198
|
+
* though a second native call is about to start (or a queue in the process
|
|
199
|
+
* of unwinding to its next task hasn't reached it yet) — exactly the window
|
|
200
|
+
* disposal must not run in. `running` stays true for the queue's entire
|
|
201
|
+
* drain, across every task in `tasks`, not just the current one, so it has
|
|
202
|
+
* no equivalent gap and closes this one.
|
|
203
|
+
*/
|
|
204
|
+
async waitForIdle(): Promise<void> {
|
|
205
|
+
while (this.currentWork || this.running) {
|
|
206
|
+
if (this.currentWork) {
|
|
207
|
+
await this.currentWork.catch(() => {});
|
|
208
|
+
} else {
|
|
209
|
+
// Nothing executing right now, but pump() is still mid-drain (about
|
|
210
|
+
// to claim the next queued task, or unwinding) — yield a microtask
|
|
211
|
+
// so it can advance instead of reporting idle into this exact gap.
|
|
212
|
+
await Promise.resolve();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
173
217
|
private poison(err: Error): void {
|
|
174
218
|
if (this.poisoned) return;
|
|
175
219
|
this.poisoned = err;
|
|
@@ -414,6 +458,16 @@ export class EmbeddingServer implements IEmbedder {
|
|
|
414
458
|
// is enough for another process to re-elect; the leaked native resources die with
|
|
415
459
|
// eventual process exit.
|
|
416
460
|
if (disposeEmbedder) {
|
|
461
|
+
// Wait for the SerialQueue to go idle BEFORE disposing. This shutdown() path
|
|
462
|
+
// also runs on ordinary leadership loss — not just the poison path above — and
|
|
463
|
+
// a legitimately slow (not hung) embed/embedMany can still be mid-flight on
|
|
464
|
+
// this same GPU context. Embedder.dispose()'s own drainActiveEmbeddings only
|
|
465
|
+
// waits 5s, an order of magnitude under the queue's own 120s soft timeout, so
|
|
466
|
+
// without this a normal large batch could have its pipeline disposed out from
|
|
467
|
+
// under it — the same concurrent-native-call hazard the queue exists to
|
|
468
|
+
// prevent, just triggered by disposal racing a live call instead of two calls
|
|
469
|
+
// racing each other.
|
|
470
|
+
await this.queue.waitForIdle();
|
|
417
471
|
await this.embedder.dispose().catch((err) => {
|
|
418
472
|
if (this.isShuttingDown) {
|
|
419
473
|
logger.debug('[EmbeddingServer] Error disposing embedder during shutdown (expected):', err);
|
|
@@ -417,7 +417,8 @@ export async function handleWebGPUWarmupError(
|
|
|
417
417
|
gpuLockHeld: boolean,
|
|
418
418
|
model: string,
|
|
419
419
|
initializationTimeoutMs: number,
|
|
420
|
-
useCache: boolean
|
|
420
|
+
useCache: boolean,
|
|
421
|
+
poolingMode: 'mean' | 'cls' | 'last_token' = 'mean'
|
|
421
422
|
): Promise<{ success: boolean; pipeline?: FeatureExtractionPipeline; error?: Error; dummy?: any }> {
|
|
422
423
|
markWebGpuFallback();
|
|
423
424
|
|
|
@@ -451,7 +452,7 @@ export async function handleWebGPUWarmupError(
|
|
|
451
452
|
logger.info('[embedder] CPU pipeline loaded successfully');
|
|
452
453
|
|
|
453
454
|
// Warmup CPU pipeline
|
|
454
|
-
const { dummy, success, error: cpuWarmupErr } = await warmupPipeline(cpuPipeline,
|
|
455
|
+
const { dummy, success, error: cpuWarmupErr } = await warmupPipeline(cpuPipeline, poolingMode, useCache);
|
|
455
456
|
if (!success || cpuWarmupErr) {
|
|
456
457
|
logger.error('[embedder] CPU fallback pipeline warmup failed:', cpuWarmupErr);
|
|
457
458
|
if (cpuPipeline) {
|
|
@@ -331,7 +331,8 @@ export class Embedder {
|
|
|
331
331
|
this.gpuLockHeld,
|
|
332
332
|
this.model,
|
|
333
333
|
this.initializationTimeoutMs,
|
|
334
|
-
this.useCache
|
|
334
|
+
this.useCache,
|
|
335
|
+
this.poolingMode
|
|
335
336
|
);
|
|
336
337
|
if (!result.success) {
|
|
337
338
|
throw result.error;
|
|
@@ -404,9 +405,16 @@ export class Embedder {
|
|
|
404
405
|
// the VECTOR — the full text is still stored verbatim and covered by FTS — but the
|
|
405
406
|
// truncation is otherwise silent, so surface it: a spike here means chunks are
|
|
406
407
|
// arriving larger than the embed cap and vector recall on their tail is degraded.
|
|
408
|
+
let keep = maxChars;
|
|
409
|
+
// slice() counts UTF-16 code units, so the cut can land between the halves of
|
|
410
|
+
// a surrogate pair (emoji, rare CJK) — same guard as truncateWithMarker
|
|
411
|
+
// (utils/text-utils.ts): a lone high surrogate is invalid text some
|
|
412
|
+
// tokenizers/providers choke on. Back off one unit to restore well-formed text.
|
|
413
|
+
const lastKeptUnit = text.charCodeAt(keep - 1);
|
|
414
|
+
if (lastKeptUnit >= 0xd800 && lastKeptUnit <= 0xdbff) keep -= 1;
|
|
407
415
|
metrics.increment('embedder_truncations_total', 1, { model: this.model });
|
|
408
|
-
logger.debug(`[embedder] Truncated input for embedding: ${text.length} -> ${
|
|
409
|
-
return text.slice(0,
|
|
416
|
+
logger.debug(`[embedder] Truncated input for embedding: ${text.length} -> ${keep} chars (model=${this.model})`);
|
|
417
|
+
return text.slice(0, keep);
|
|
410
418
|
}
|
|
411
419
|
return text;
|
|
412
420
|
}
|
package/src/knowledge/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
2
3
|
import { KnowledgeStore } from './store.ts';
|
|
3
4
|
import { WriterQueue } from './writer-queue.ts';
|
|
4
5
|
import { Chunker } from './chunker.ts';
|
|
@@ -108,19 +109,44 @@ export async function createKnowledgeStoreComponents(
|
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
/**
|
|
111
|
-
* Force delete the
|
|
112
|
-
*
|
|
112
|
+
* Force delete the ACTIVE table's own directory, to recover from a corrupted
|
|
113
|
+
* LanceDB manifest. Scoped to that one table, not the whole dbDir: dbDir is a
|
|
114
|
+
* single unified store shared by every workspace and the global scope (rows
|
|
115
|
+
* are scoped by a `workspace` column, not by separate directories), and it
|
|
116
|
+
* also holds `_backup_*`/`_migration_*` sibling directories — the deliberate,
|
|
117
|
+
* user-recoverable safety net the 'backup' migration strategy creates. A
|
|
118
|
+
* directory-wide wipe here previously destroyed every other project's data
|
|
119
|
+
* and every backup the instant ANY workspace's session hit a corrupted
|
|
120
|
+
* manifest, on the one code path whose entire purpose is recovering from
|
|
121
|
+
* exactly that kind of damage.
|
|
113
122
|
*/
|
|
114
123
|
export async function forceDeleteKnowledgeStore(config?: Config, workspace?: string): Promise<void> {
|
|
115
124
|
const dbDir = getDbDir(config, workspace);
|
|
116
|
-
if (fs.existsSync(dbDir))
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
125
|
+
if (!fs.existsSync(dbDir)) return;
|
|
126
|
+
|
|
127
|
+
// Mirrors KnowledgeStore's own manifest read (store.ts): the active table
|
|
128
|
+
// name defaults to 'knowledge' but can point elsewhere after a persisted
|
|
129
|
+
// rename failure. A missing or unreadable manifest is not itself a reason to
|
|
130
|
+
// widen the blast radius, so it falls back to the default rather than
|
|
131
|
+
// escalating to a dbDir-wide delete.
|
|
132
|
+
let tableName = 'knowledge';
|
|
133
|
+
const manifestPath = path.join(dbDir, 'store-manifest.json');
|
|
134
|
+
try {
|
|
135
|
+
if (fs.existsSync(manifestPath)) {
|
|
136
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as { activeTableName?: string };
|
|
137
|
+
if (manifest.activeTableName) tableName = manifest.activeTableName;
|
|
124
138
|
}
|
|
139
|
+
} catch (err) {
|
|
140
|
+
logger.warn(`[knowledge] Failed to read manifest before forced delete, defaulting to table '${tableName}':`, err);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const tableDir = path.join(dbDir, `${tableName}.lance`);
|
|
144
|
+
if (!fs.existsSync(tableDir)) return;
|
|
145
|
+
try {
|
|
146
|
+
fs.rmSync(tableDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
147
|
+
logger.info(`[knowledge] Corrupted table directory ${tableDir} forcefully deleted; will be recreated on next init.`);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
logger.error(`[knowledge] Failed to forcefully delete table directory ${tableDir}:`, err);
|
|
150
|
+
throw err;
|
|
125
151
|
}
|
|
126
152
|
}
|
package/src/knowledge/store.ts
CHANGED
|
@@ -502,12 +502,92 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
502
502
|
// If canonical name was already something else and it still exists,
|
|
503
503
|
// we might need to handle it. But we just dropped it above.
|
|
504
504
|
await fsPromises.rename(tempDir, canonicalDir);
|
|
505
|
-
|
|
505
|
+
|
|
506
506
|
this.tableName = canonicalName;
|
|
507
|
-
await this.saveManifest()
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
507
|
+
if (await this.saveManifest()) {
|
|
508
|
+
// Reopen the canonical table so this.table reflects the renamed path
|
|
509
|
+
this.table = await this.db.openTable(this.tableName);
|
|
510
|
+
} else {
|
|
511
|
+
// The directory rename to the canonical name already happened on disk,
|
|
512
|
+
// but the manifest still says tempTableName (persisted successfully
|
|
513
|
+
// above, before the old table was dropped). Left as-is, the next
|
|
514
|
+
// process start would read that stale name, find no such directory
|
|
515
|
+
// (renamed away), and silently CREATE A NEW EMPTY TABLE under it —
|
|
516
|
+
// stranding every re-embedded document in the now-unreferenced
|
|
517
|
+
// canonical directory with no recovery path (pruneOrphanedMigrationDirs
|
|
518
|
+
// doesn't recognize a bare 'knowledge.lance' as orphaned migration
|
|
519
|
+
// material). Revert the rename so the manifest (still correctly
|
|
520
|
+
// pointing at tempTableName, from the pre-drop save above) and the
|
|
521
|
+
// on-disk layout agree again — the data stays fully intact and
|
|
522
|
+
// accessible under its temp name either way. Deliberately NOT thrown:
|
|
523
|
+
// the migration itself fully succeeded and the in-memory handle is
|
|
524
|
+
// fine, so surfacing this as a migration failure would trip the
|
|
525
|
+
// caller's re-embed-failed fallback to the 'backup' strategy, which
|
|
526
|
+
// would needlessly rename this perfectly good, fully re-embedded data
|
|
527
|
+
// out of the way and start a fresh empty table over one manifest
|
|
528
|
+
// write hiccup.
|
|
529
|
+
logger.error('[store] Failed to persist canonical table name after rename — reverting to keep manifest and disk in sync.');
|
|
530
|
+
try {
|
|
531
|
+
await fsPromises.rename(canonicalDir, tempDir);
|
|
532
|
+
this.tableName = tempTableName;
|
|
533
|
+
this.table = await this.db.openTable(this.tableName);
|
|
534
|
+
logger.info(`[store] Migrated data remains safe and accessible as ${tempTableName}; canonical rename deferred to a future successful manifest save.`);
|
|
535
|
+
} catch (revertErr) {
|
|
536
|
+
// The revert itself failed too. fs.rename is atomic on a single
|
|
537
|
+
// filesystem — a failed rename never leaves data half-moved — so
|
|
538
|
+
// the data is still fully at canonicalDir, not tempDir. The outer
|
|
539
|
+
// catch below was written for a DIFFERENT case (the first rename
|
|
540
|
+
// never happening at all) and would wrongly point the manifest at
|
|
541
|
+
// tempTableName, whose directory no longer exists on disk. Instead,
|
|
542
|
+
// try to persist a manifest against the name that actually matches
|
|
543
|
+
// disk right now, so the mismatch this whole branch exists to avoid
|
|
544
|
+
// doesn't reappear one level deeper.
|
|
545
|
+
logger.error(`[store] Revert rename also failed; data remains on disk at ${canonicalDir}: ${revertErr}`);
|
|
546
|
+
// Everything from here on is wrapped in its OWN try/catch and never
|
|
547
|
+
// rethrows: a third compounding failure (e.g. openTable() itself
|
|
548
|
+
// throwing, on the same underlying disk fault that already broke
|
|
549
|
+
// two renames) must not escape to the outer catch (renameErr)
|
|
550
|
+
// either — that one assumes data is still at tempDir, which by
|
|
551
|
+
// this point is doubly false.
|
|
552
|
+
// The manifest repair is deliberately NOT gated behind the table
|
|
553
|
+
// reopen: saveManifest depends only on writeFile/rename, not on an
|
|
554
|
+
// open table, so a reopen-only failure (a LanceDB-level fault on an
|
|
555
|
+
// otherwise-writable filesystem) must not skip a repair the code can
|
|
556
|
+
// still make — a skipped repair leaves the persisted manifest
|
|
557
|
+
// pointing at the nonexistent temp name, which is the next-restart
|
|
558
|
+
// empty-table hazard this whole branch exists to prevent.
|
|
559
|
+
this.tableName = canonicalName;
|
|
560
|
+
let reopened = false;
|
|
561
|
+
try {
|
|
562
|
+
this.table = await this.db.openTable(this.tableName);
|
|
563
|
+
reopened = true;
|
|
564
|
+
} catch (openErr) {
|
|
565
|
+
// A third compounding failure: even reopening the table at its
|
|
566
|
+
// actual on-disk location failed. This process has no working
|
|
567
|
+
// table handle for it, but the data itself should still be
|
|
568
|
+
// physically intact at canonicalDir (only the rename and its
|
|
569
|
+
// revert have run, and neither drops data — they only move a
|
|
570
|
+
// directory or fail outright). The manifest repair below still
|
|
571
|
+
// proceeds, so a future restart can find the data even though
|
|
572
|
+
// this process cannot serve it.
|
|
573
|
+
logger.error(`[store] CRITICAL: could not reopen the table at '${canonicalName}' after a failed rename revert (${openErr}). Data should still be intact on disk at ${canonicalDir}, but this process has no working table handle for it. Attempting the manifest repair anyway so a restart can find the data; verify ${canonicalDir} exists if problems persist.`);
|
|
574
|
+
}
|
|
575
|
+
try {
|
|
576
|
+
if (await this.saveManifest()) {
|
|
577
|
+
logger.warn(`[store] Recovered: manifest now points at '${canonicalName}', matching the data's actual on-disk location${reopened ? '' : ' (this process still has no working table handle — restart to serve it)'}.`);
|
|
578
|
+
} else {
|
|
579
|
+
// Both the revert and this recovery manifest save failed. The
|
|
580
|
+
// on-disk manifest (last persisted pointing at tempTableName,
|
|
581
|
+
// before the old table was dropped) now disagrees with disk.
|
|
582
|
+
// Fail loud rather than let a future restart silently create
|
|
583
|
+
// an empty table under the stale name.
|
|
584
|
+
logger.error(`[store] CRITICAL: could not persist a manifest matching disk after a failed rename revert. Data is intact on disk at ${canonicalDir} (table '${canonicalName}'), but the persisted manifest is stale and points at '${tempTableName}', which no longer exists. If this process exits before a future successful manifest save, the next start will not find the data automatically — manually repair the manifest to point at '${canonicalName}' before restarting.`);
|
|
585
|
+
}
|
|
586
|
+
} catch (manifestErr) {
|
|
587
|
+
logger.error(`[store] CRITICAL: manifest repair threw after a failed rename revert (${manifestErr}). Data is intact on disk at ${canonicalDir} (table '${canonicalName}'), but the persisted manifest is stale and points at '${tempTableName}'. Manually repair the manifest to point at '${canonicalName}' before restarting.`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
511
591
|
} catch (renameErr) {
|
|
512
592
|
// If rename fails (cross-device, permissions, etc.) we keep the temp name
|
|
513
593
|
// but CRITICALLY we now persist it in the manifest so future sessions
|
|
@@ -43,6 +43,14 @@ export interface ObserverContext {
|
|
|
43
43
|
*/
|
|
44
44
|
renderImmediate?: () => void;
|
|
45
45
|
researchComplexity: number;
|
|
46
|
+
/**
|
|
47
|
+
* Whether prompt caching is active for the run's resolved model (see
|
|
48
|
+
* isPromptCachingActiveForModel). With caching active the effective
|
|
49
|
+
* per-researcher scrape-batch maximum is one higher, and progress units
|
|
50
|
+
* must be computed relative to that same effective count the tracker
|
|
51
|
+
* enforces — not the base configured value.
|
|
52
|
+
*/
|
|
53
|
+
cachingActive?: boolean;
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
export interface ObserverState {
|
|
@@ -94,7 +102,7 @@ export function createResearchObserver(
|
|
|
94
102
|
activateSlice(panelState, state.quickSliceLabel);
|
|
95
103
|
updateSliceStatus(panelState, state.quickSliceLabel, 'starting...', debouncedRefresh);
|
|
96
104
|
|
|
97
|
-
const units = getUnitsPerResearcher();
|
|
105
|
+
const units = getUnitsPerResearcher(undefined, ctx.cachingActive);
|
|
98
106
|
panelState.progress = { expected: units, made: 0 };
|
|
99
107
|
}
|
|
100
108
|
// Quick research: researcher is running → steering is acceptable
|
|
@@ -152,7 +160,7 @@ export function createResearchObserver(
|
|
|
152
160
|
onPlanningSuccess: (plan) => {
|
|
153
161
|
updateSliceStatus(panelState, 'coord', 'ready', debouncedRefresh);
|
|
154
162
|
completeSlice(panelState, 'coord');
|
|
155
|
-
const unitsPerResearcher = getUnitsPerResearcher();
|
|
163
|
+
const unitsPerResearcher = getUnitsPerResearcher(undefined, ctx.cachingActive);
|
|
156
164
|
const count = plan.researchers?.length || 0;
|
|
157
165
|
const units = (count * unitsPerResearcher) + LEAD_EVAL_UNITS;
|
|
158
166
|
panelState.progress = { expected: units, made: 0 };
|
|
@@ -319,7 +327,7 @@ export function createResearchObserver(
|
|
|
319
327
|
|
|
320
328
|
onResearcherProgress: (id, status, tokens, cost) => {
|
|
321
329
|
const sliceId = id === 'quick' ? state.quickSliceLabel : id;
|
|
322
|
-
const unitsPerResearcher = getUnitsPerResearcher();
|
|
330
|
+
const unitsPerResearcher = getUnitsPerResearcher(undefined, ctx.cachingActive);
|
|
323
331
|
|
|
324
332
|
if (status !== undefined) {
|
|
325
333
|
if (status.startsWith('done:')) {
|
|
@@ -350,7 +358,7 @@ export function createResearchObserver(
|
|
|
350
358
|
onResearcherComplete: (id, _report) => {
|
|
351
359
|
const sliceId = id === 'quick' ? state.quickSliceLabel : id;
|
|
352
360
|
if (panelState.progress) {
|
|
353
|
-
const unitsPerResearcher = getUnitsPerResearcher();
|
|
361
|
+
const unitsPerResearcher = getUnitsPerResearcher(undefined, ctx.cachingActive);
|
|
354
362
|
const current = progressCredits.get(id) ?? 0;
|
|
355
363
|
const remaining = unitsPerResearcher - current;
|
|
356
364
|
if (remaining > 0) {
|
|
@@ -365,7 +373,7 @@ export function createResearchObserver(
|
|
|
365
373
|
onResearcherFailure: (id, _error) => {
|
|
366
374
|
const sliceId = id === 'quick' ? state.quickSliceLabel : id;
|
|
367
375
|
if (panelState.progress) {
|
|
368
|
-
const unitsPerResearcher = getUnitsPerResearcher();
|
|
376
|
+
const unitsPerResearcher = getUnitsPerResearcher(undefined, ctx.cachingActive);
|
|
369
377
|
const current = progressCredits.get(id) ?? 0;
|
|
370
378
|
const remaining = unitsPerResearcher - current;
|
|
371
379
|
if (remaining > 0) {
|
|
@@ -430,7 +438,7 @@ export function createResearchObserver(
|
|
|
430
438
|
} else {
|
|
431
439
|
// Delegation: prepare for new round's search → researchers → steering acceptable
|
|
432
440
|
if (plan?.researchers && plan.researchers.length > 0 && panelState.progress) {
|
|
433
|
-
const unitsPerResearcher = getUnitsPerResearcher();
|
|
441
|
+
const unitsPerResearcher = getUnitsPerResearcher(undefined, ctx.cachingActive);
|
|
434
442
|
panelState.progress.expected += (plan.researchers.length * unitsPerResearcher) + LEAD_EVAL_UNITS;
|
|
435
443
|
}
|
|
436
444
|
panelState.steeringAcceptable = true;
|
|
@@ -517,7 +517,9 @@ export class DeepResearchOrchestrator {
|
|
|
517
517
|
// Guarded so a one-round budget cannot announce the same round twice.
|
|
518
518
|
if (this.currentRound > lastAnnouncedRound) {
|
|
519
519
|
observer?.onRoundStart?.(this.currentRound);
|
|
520
|
-
lastAnnouncedRound
|
|
520
|
+
// No assignment to lastAnnouncedRound here: this is the last place in the
|
|
521
|
+
// function that reads it — final synthesis proceeds unconditionally right
|
|
522
|
+
// after, nothing downstream checks it again.
|
|
521
523
|
}
|
|
522
524
|
// The round this synthesis actually happens at, not the budget. They coincide on
|
|
523
525
|
// the round-cap exit but not on the wait-exhaustion one, which breaks out early —
|
|
@@ -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,
|