@lincoln504/pi-research 1.4.3 → 1.5.1
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 +396 -125
- 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 +17 -4
- package/docs/AGENT-SKILL.md +4 -3
- package/docs/CHANGELOG.md +131 -2
- package/docs/CONFIGURATION.md +2 -2
- package/docs/SDK.md +1 -1
- package/package.json +3 -2
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +44 -3
- package/src/config.ts +31 -9
- package/src/constants.ts +23 -4
- package/src/core/interfaces/state-manager-interfaces.ts +8 -0
- package/src/core/llm/research-model-resolver.ts +38 -0
- package/src/core/planning-service.ts +39 -4
- package/src/index.ts +3 -1
- package/src/infrastructure/browser/thread-worker-browser.ts +10 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +20 -3
- package/src/infrastructure/browser/thread-worker.mjs +17 -4
- package/src/infrastructure/embedding/embedding-factory.ts +34 -0
- package/src/infrastructure/embedding/embedding-server.ts +34 -9
- package/src/infrastructure/state/state-manager.ts +15 -0
- package/src/knowledge/embedder.ts +36 -7
- package/src/knowledge/store-operations.ts +47 -38
- package/src/knowledge/store.ts +86 -8
- package/src/knowledge/writer-queue.ts +4 -2
- package/src/observers/research-observer-impl.ts +14 -6
- package/src/orchestration/deep-research-orchestrator.ts +15 -3
- package/src/orchestration/quick-research-orchestrator.ts +37 -11
- package/src/orchestration/research-orchestration-service.ts +21 -2
- package/src/orchestration/research-synthesis-service.ts +36 -0
- package/src/orchestration/researcher-executor.ts +46 -17
- package/src/orchestration/researcher.ts +2 -2
- package/src/orchestration/session-state.ts +36 -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 +65 -52
- package/src/stackexchange/index.ts +8 -0
- package/src/tools/health-tool-definition.ts +11 -1
- package/src/tools/index.ts +2 -1
- package/src/tools/research-knowledge-search.ts +58 -10
- package/src/tools/research-tool-definition.ts +27 -16
- package/src/tools/scrape.ts +8 -1
- package/src/tools/stackexchange.ts +14 -7
- package/src/utils/llm-usage.ts +4 -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/scraper-utils.ts +6 -2
- package/src/web-research/web-scraper.ts +9 -0
- package/src/youtube/index.ts +11 -1
- package/src/youtube/potoken.ts +20 -2
|
@@ -50,6 +50,14 @@ export class Embedder {
|
|
|
50
50
|
private initializingPromise: Promise<void> | null = null;
|
|
51
51
|
private disposePromise: Promise<void> | null = null;
|
|
52
52
|
private recoveryPromise: Promise<void> | null = null;
|
|
53
|
+
// Latched by the SerialQueue poison path (via EmbeddingServer): a native
|
|
54
|
+
// inference is permanently hung inside this pipeline. dispose() must then
|
|
55
|
+
// never call pipeline.dispose() — it would join the stuck native thread, the
|
|
56
|
+
// exact hazard the poison path's shutdown(false) avoided — yet this instance
|
|
57
|
+
// stays registered for process-exit cleanup (registerGlobalEmbedder), whose
|
|
58
|
+
// task calls dispose() unconditionally. Without the latch the teardown path
|
|
59
|
+
// re-created the hang the step-down deliberately abandoned.
|
|
60
|
+
private pipelineWedged = false;
|
|
53
61
|
|
|
54
62
|
private model: string;
|
|
55
63
|
private poolingMode: 'mean' | 'cls' | 'last_token';
|
|
@@ -656,6 +664,15 @@ export class Embedder {
|
|
|
656
664
|
}
|
|
657
665
|
}
|
|
658
666
|
|
|
667
|
+
/**
|
|
668
|
+
* Mark this embedder's pipeline as permanently wedged (a native inference hung
|
|
669
|
+
* past the SerialQueue hard deadline). From here on, dispose() abandons the
|
|
670
|
+
* pipeline instead of calling pipeline.dispose() — see pipelineWedged.
|
|
671
|
+
*/
|
|
672
|
+
markPipelineWedged(): void {
|
|
673
|
+
this.pipelineWedged = true;
|
|
674
|
+
}
|
|
675
|
+
|
|
659
676
|
/**
|
|
660
677
|
* @param reason `'idle'` for the memory-reclaim pause driven by the idle timer —
|
|
661
678
|
* a later embed is allowed to revive the embedder. Anything else is terminal
|
|
@@ -678,9 +695,13 @@ export class Embedder {
|
|
|
678
695
|
this.disposeReason = reason;
|
|
679
696
|
this.state = 'disposing';
|
|
680
697
|
this.disposePromise = (async () => {
|
|
681
|
-
// Wait for all active embeddings to complete
|
|
698
|
+
// Wait for all active embeddings to complete. Pointless when the pipeline
|
|
699
|
+
// is wedged: the hung call holds its activeEmbeddings slot forever, so the
|
|
700
|
+
// drain can only time out — skip straight to the (dispose-skipping)
|
|
701
|
+
// teardown below instead of burning the wait twice at process exit.
|
|
682
702
|
const maxWaitMs = 5000;
|
|
683
703
|
const drainActiveEmbeddings = async (): Promise<void> => {
|
|
704
|
+
if (this.pipelineWedged) return;
|
|
684
705
|
const startTime = Date.now();
|
|
685
706
|
while (this.activeEmbeddings > 0 && (Date.now() - startTime) < maxWaitMs) {
|
|
686
707
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
@@ -709,14 +730,22 @@ export class Embedder {
|
|
|
709
730
|
}
|
|
710
731
|
|
|
711
732
|
if (this.pipeline) {
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
733
|
+
if (this.pipelineWedged) {
|
|
734
|
+
// See pipelineWedged's declaration: disposing a wedged ORT session
|
|
735
|
+
// joins the permanently hung native thread. Abandon it — the native
|
|
736
|
+
// resources die with process exit, which is where this path runs.
|
|
737
|
+
logger.warn('[embedder] Skipping pipeline.dispose(): pipeline wedged by a permanently hung inference; abandoning native resources to process exit.');
|
|
738
|
+
this.pipeline = null;
|
|
739
|
+
} else {
|
|
740
|
+
try {
|
|
741
|
+
if (typeof (this.pipeline as any).dispose === 'function') {
|
|
742
|
+
await (this.pipeline as DisposablePipeline).dispose();
|
|
743
|
+
}
|
|
744
|
+
} catch (err) {
|
|
745
|
+
logger.warn('[embedder] Error during pipeline dispose:', err);
|
|
715
746
|
}
|
|
716
|
-
|
|
717
|
-
logger.warn('[embedder] Error during pipeline dispose:', err);
|
|
747
|
+
this.pipeline = null;
|
|
718
748
|
}
|
|
719
|
-
this.pipeline = null;
|
|
720
749
|
}
|
|
721
750
|
|
|
722
751
|
// Always release the GPU lock once on dispose (defensive: the gpuLockHeld flag
|
|
@@ -13,6 +13,52 @@ import { logger } from '../logger.ts';
|
|
|
13
13
|
import { metrics } from '../utils/metrics.ts';
|
|
14
14
|
import { StoreDocument } from '../core/interfaces/knowledge-interfaces.ts';
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Guard against a broken embedder silently writing corrupt vectors: a
|
|
18
|
+
* wrong-count, empty, ragged, or NaN/Inf-poisoned result would otherwise land
|
|
19
|
+
* in the FixedSizeList column and surface only as an opaque Arrow error or,
|
|
20
|
+
* worse, as silently degraded search (NaN distances break ANN ordering). Fail
|
|
21
|
+
* loudly at the last gate before persistence instead. Shared by the normal
|
|
22
|
+
* addDocuments path and the re-embed migration (which writes vectors through
|
|
23
|
+
* its own table handle and previously had no guard at all).
|
|
24
|
+
*/
|
|
25
|
+
export function assertValidEmbeddingVectors(
|
|
26
|
+
vectors: (Float32Array | number[])[],
|
|
27
|
+
expectedCount: number,
|
|
28
|
+
declaredDim: number | null | undefined,
|
|
29
|
+
): void {
|
|
30
|
+
if (vectors.length !== expectedCount) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`[store] embedder returned ${vectors.length} vectors for ${expectedCount} documents`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
// Expected width: the embedder's declared dimension, falling back to the
|
|
36
|
+
// first vector's width. Every vector must match it exactly — a uniformly
|
|
37
|
+
// wrong width is just as corrupting as a ragged one.
|
|
38
|
+
const dim = declaredDim && declaredDim > 0 ? declaredDim : (vectors[0]?.length ?? 0);
|
|
39
|
+
if (dim === 0) {
|
|
40
|
+
throw new Error('[store] embedder returned empty/zero-width vectors');
|
|
41
|
+
}
|
|
42
|
+
for (let i = 0; i < vectors.length; i++) {
|
|
43
|
+
const v = vectors[i];
|
|
44
|
+
if (!v || v.length !== dim) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`[store] embedder returned a wrong-width vector at index ${i} (got ${v?.length ?? 0}, expected ${dim})`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
// A correct-width vector can still be poisoned: a WebGPU device-lost
|
|
50
|
+
// mid-inference, or a truncated server response coerced through
|
|
51
|
+
// Float32Array, yields NaN/Inf entries that pass every length check.
|
|
52
|
+
for (let j = 0; j < v.length; j++) {
|
|
53
|
+
if (!Number.isFinite(v[j]!)) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`[store] embedder returned a non-finite value (${v[j]}) at vector ${i}, position ${j}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
16
62
|
/**
|
|
17
63
|
* Add documents to the store
|
|
18
64
|
*/
|
|
@@ -35,44 +81,7 @@ export async function addDocumentsToStore(
|
|
|
35
81
|
|
|
36
82
|
try {
|
|
37
83
|
const vectors = await embedder.embedMany(docs.map(d => d.text));
|
|
38
|
-
|
|
39
|
-
// Guard against a broken embedder silently writing corrupt vectors: a
|
|
40
|
-
// wrong-count, empty, or ragged result would otherwise land in the
|
|
41
|
-
// FixedSizeList column and surface only as an opaque Arrow error or, worse,
|
|
42
|
-
// as silently degraded search. Fail loudly here instead.
|
|
43
|
-
if (vectors.length !== docs.length) {
|
|
44
|
-
throw new Error(
|
|
45
|
-
`[store] embedder returned ${vectors.length} vectors for ${docs.length} documents`,
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
|
-
// Expected width: the embedder's declared dimension, falling back to the
|
|
49
|
-
// first vector's width. Every vector must match it exactly — a uniformly
|
|
50
|
-
// wrong width is just as corrupting as a ragged one.
|
|
51
|
-
const declaredDim = embedder.getDimension?.();
|
|
52
|
-
const dim = declaredDim && declaredDim > 0 ? declaredDim : (vectors[0]?.length ?? 0);
|
|
53
|
-
if (dim === 0) {
|
|
54
|
-
throw new Error('[store] embedder returned empty/zero-width vectors');
|
|
55
|
-
}
|
|
56
|
-
for (let i = 0; i < vectors.length; i++) {
|
|
57
|
-
const v = vectors[i];
|
|
58
|
-
if (!v || v.length !== dim) {
|
|
59
|
-
throw new Error(
|
|
60
|
-
`[store] embedder returned a wrong-width vector at index ${i} (got ${v?.length ?? 0}, expected ${dim})`,
|
|
61
|
-
);
|
|
62
|
-
}
|
|
63
|
-
// A correct-width vector can still be poisoned: a WebGPU device-lost
|
|
64
|
-
// mid-inference, or a truncated server response coerced through
|
|
65
|
-
// Float32Array, yields NaN/Inf entries that pass every length check but
|
|
66
|
-
// silently corrupt the ANN index (NaN distances break ordering). Scan
|
|
67
|
-
// here — this is the last gate before the vector is persisted.
|
|
68
|
-
for (let j = 0; j < v.length; j++) {
|
|
69
|
-
if (!Number.isFinite(v[j]!)) {
|
|
70
|
-
throw new Error(
|
|
71
|
-
`[store] embedder returned a non-finite value (${v[j]}) at vector ${i}, position ${j}`,
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
84
|
+
assertValidEmbeddingVectors(vectors, docs.length, embedder.getDimension?.());
|
|
76
85
|
|
|
77
86
|
const data = docs.map((doc, i) => ({
|
|
78
87
|
vector: vectors[i]!,
|
package/src/knowledge/store.ts
CHANGED
|
@@ -22,7 +22,7 @@ import * as path from 'node:path';
|
|
|
22
22
|
import { MigrationStrategy, MigrationResult } from './migration.ts';
|
|
23
23
|
import { metrics } from '../utils/metrics.ts';
|
|
24
24
|
import { createStoreTable, CURRENT_SCHEMA_VERSION } from './store-schema.ts';
|
|
25
|
-
import { addDocumentsToStore, searchStore, findDocumentsByUrl, findRelevantUrls } from './store-operations.ts';
|
|
25
|
+
import { addDocumentsToStore, assertValidEmbeddingVectors, searchStore, findDocumentsByUrl, findRelevantUrls } from './store-operations.ts';
|
|
26
26
|
import { isEmbedderUnreachable } from './embedder-utils.ts';
|
|
27
27
|
import { ServiceLifecycle } from '../core/service-registry.ts';
|
|
28
28
|
import { ServiceNames } from '../core/service-interfaces.ts';
|
|
@@ -448,7 +448,16 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
448
448
|
});
|
|
449
449
|
|
|
450
450
|
const texts = batchDocs.map(d => d.text);
|
|
451
|
-
|
|
451
|
+
// Reconnect-wrapped like the warm-up embed in openInternal: a re-embed
|
|
452
|
+
// migration is a LONG operation, exactly when a leader idle-dispose /
|
|
453
|
+
// step-down (ECONNREFUSED fast-fail) is most likely — and any throw
|
|
454
|
+
// here escalates through openInternal's fallback ladder to the 'backup'
|
|
455
|
+
// strategy, which replaces the fully-intact table with an EMPTY one.
|
|
456
|
+
// One transient blip must not convert "preserve my data" into that.
|
|
457
|
+
const vectors = await this.withEmbedderReconnect(embedder => embedder.embedMany(texts));
|
|
458
|
+
// Same last-gate integrity check as addDocumentsToStore: this path
|
|
459
|
+
// writes vectors through its own table handle and previously had none.
|
|
460
|
+
assertValidEmbeddingVectors(vectors, batchDocs.length, this.options.embedder.getDimension?.());
|
|
452
461
|
|
|
453
462
|
const records = batchDocs.map((doc, idx) => ({
|
|
454
463
|
vector: Array.from(vectors[idx]!),
|
|
@@ -527,10 +536,66 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
527
536
|
// out of the way and start a fresh empty table over one manifest
|
|
528
537
|
// write hiccup.
|
|
529
538
|
logger.error('[store] Failed to persist canonical table name after rename — reverting to keep manifest and disk in sync.');
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
539
|
+
try {
|
|
540
|
+
await fsPromises.rename(canonicalDir, tempDir);
|
|
541
|
+
this.tableName = tempTableName;
|
|
542
|
+
this.table = await this.db.openTable(this.tableName);
|
|
543
|
+
logger.info(`[store] Migrated data remains safe and accessible as ${tempTableName}; canonical rename deferred to a future successful manifest save.`);
|
|
544
|
+
} catch (revertErr) {
|
|
545
|
+
// The revert itself failed too. fs.rename is atomic on a single
|
|
546
|
+
// filesystem — a failed rename never leaves data half-moved — so
|
|
547
|
+
// the data is still fully at canonicalDir, not tempDir. The outer
|
|
548
|
+
// catch below was written for a DIFFERENT case (the first rename
|
|
549
|
+
// never happening at all) and would wrongly point the manifest at
|
|
550
|
+
// tempTableName, whose directory no longer exists on disk. Instead,
|
|
551
|
+
// try to persist a manifest against the name that actually matches
|
|
552
|
+
// disk right now, so the mismatch this whole branch exists to avoid
|
|
553
|
+
// doesn't reappear one level deeper.
|
|
554
|
+
logger.error(`[store] Revert rename also failed; data remains on disk at ${canonicalDir}: ${revertErr}`);
|
|
555
|
+
// Everything from here on is wrapped in its OWN try/catch and never
|
|
556
|
+
// rethrows: a third compounding failure (e.g. openTable() itself
|
|
557
|
+
// throwing, on the same underlying disk fault that already broke
|
|
558
|
+
// two renames) must not escape to the outer catch (renameErr)
|
|
559
|
+
// either — that one assumes data is still at tempDir, which by
|
|
560
|
+
// this point is doubly false.
|
|
561
|
+
// The manifest repair is deliberately NOT gated behind the table
|
|
562
|
+
// reopen: saveManifest depends only on writeFile/rename, not on an
|
|
563
|
+
// open table, so a reopen-only failure (a LanceDB-level fault on an
|
|
564
|
+
// otherwise-writable filesystem) must not skip a repair the code can
|
|
565
|
+
// still make — a skipped repair leaves the persisted manifest
|
|
566
|
+
// pointing at the nonexistent temp name, which is the next-restart
|
|
567
|
+
// empty-table hazard this whole branch exists to prevent.
|
|
568
|
+
this.tableName = canonicalName;
|
|
569
|
+
let reopened = false;
|
|
570
|
+
try {
|
|
571
|
+
this.table = await this.db.openTable(this.tableName);
|
|
572
|
+
reopened = true;
|
|
573
|
+
} catch (openErr) {
|
|
574
|
+
// A third compounding failure: even reopening the table at its
|
|
575
|
+
// actual on-disk location failed. This process has no working
|
|
576
|
+
// table handle for it, but the data itself should still be
|
|
577
|
+
// physically intact at canonicalDir (only the rename and its
|
|
578
|
+
// revert have run, and neither drops data — they only move a
|
|
579
|
+
// directory or fail outright). The manifest repair below still
|
|
580
|
+
// proceeds, so a future restart can find the data even though
|
|
581
|
+
// this process cannot serve it.
|
|
582
|
+
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.`);
|
|
583
|
+
}
|
|
584
|
+
try {
|
|
585
|
+
if (await this.saveManifest()) {
|
|
586
|
+
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)'}.`);
|
|
587
|
+
} else {
|
|
588
|
+
// Both the revert and this recovery manifest save failed. The
|
|
589
|
+
// on-disk manifest (last persisted pointing at tempTableName,
|
|
590
|
+
// before the old table was dropped) now disagrees with disk.
|
|
591
|
+
// Fail loud rather than let a future restart silently create
|
|
592
|
+
// an empty table under the stale name.
|
|
593
|
+
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.`);
|
|
594
|
+
}
|
|
595
|
+
} catch (manifestErr) {
|
|
596
|
+
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.`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
534
599
|
}
|
|
535
600
|
} catch (renameErr) {
|
|
536
601
|
// If rename fails (cross-device, permissions, etc.) we keep the temp name
|
|
@@ -951,11 +1016,24 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
951
1016
|
// coexist, and LanceDB's fragment-scan order typically returns the OLDEST row
|
|
952
1017
|
// first — limit(1) would serve the superseded content (and feed its older
|
|
953
1018
|
// timestamp to the serve-age gate) until the next successful ingest.
|
|
954
|
-
const
|
|
1019
|
+
const whereClause = `url = '${escapedUrl}' AND ingestion_type = 'synthesis-description' AND (${scopeFilter})`;
|
|
1020
|
+
let results = await table
|
|
955
1021
|
.query()
|
|
956
|
-
.where(
|
|
1022
|
+
.where(whereClause)
|
|
957
1023
|
.limit(16)
|
|
958
1024
|
.toArray();
|
|
1025
|
+
if (results.length === 16) {
|
|
1026
|
+
// Cap hit: the OLD generation alone may have filled all 16 slots (a long
|
|
1027
|
+
// description chunks into many rows), leaving the newest generation
|
|
1028
|
+
// entirely outside the fragment-scan window — the sort below would then
|
|
1029
|
+
// pick "newest of the stale". Re-fetch with a bound two generations of
|
|
1030
|
+
// one URL cannot plausibly reach.
|
|
1031
|
+
results = await table
|
|
1032
|
+
.query()
|
|
1033
|
+
.where(whereClause)
|
|
1034
|
+
.limit(1024)
|
|
1035
|
+
.toArray();
|
|
1036
|
+
}
|
|
959
1037
|
|
|
960
1038
|
const duration = Date.now() - startTime;
|
|
961
1039
|
metrics.observe('knowledge_store_query_duration_ms', duration, { operation: 'rebuild_document' });
|
|
@@ -181,8 +181,10 @@ export class WriterQueue implements IWriterQueue {
|
|
|
181
181
|
};
|
|
182
182
|
|
|
183
183
|
// Dedup: check if we already have this exact URL+type with identical content.
|
|
184
|
-
// findByUrl applies scope filtering, so this only dedupes within the
|
|
185
|
-
//
|
|
184
|
+
// findByUrl applies scope filtering, so this only dedupes within the current
|
|
185
|
+
// scope's VISIBLE rows — in project mode that is the workspace's own rows only
|
|
186
|
+
// (getScopeFilter() in project mode excludes global rows), which is correct because
|
|
187
|
+
// project mode doesn't serve global rows either.
|
|
186
188
|
// Compared against the NEWEST generation, not row order: after a failed prune
|
|
187
189
|
// two generations coexist and findByUrl's order is arbitrary — matching the
|
|
188
190
|
// OLD generation's hash would skip the refresh with the duplicates in place.
|
|
@@ -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;
|
|
@@ -226,13 +226,18 @@ export class DeepResearchOrchestrator {
|
|
|
226
226
|
// to run the SAME round again. Announcing it each time emitted `round_start`
|
|
227
227
|
// 1, 2, 2, 3 — a duplicate rather than the gap the post-loop guard was added for,
|
|
228
228
|
// but the same broken contract for a consumer counting rounds.
|
|
229
|
-
|
|
229
|
+
const firstEntryThisRound = this.currentRound > lastAnnouncedRound;
|
|
230
|
+
if (firstEntryThisRound) {
|
|
230
231
|
observer?.onRoundStart?.(this.currentRound);
|
|
231
232
|
lastAnnouncedRound = this.currentRound;
|
|
232
233
|
}
|
|
233
234
|
|
|
234
235
|
// Check infrastructure health (advisory: logs status, never aborts the run).
|
|
235
|
-
|
|
236
|
+
// Once per round, not per loop entry: a 'wait' retry re-enters the same
|
|
237
|
+
// round and must not re-probe the same infrastructure it just probed.
|
|
238
|
+
if (firstEntryThisRound) {
|
|
239
|
+
await orchestrationService.checkHealth(this.currentRound, researchId, ctx);
|
|
240
|
+
}
|
|
236
241
|
|
|
237
242
|
// 1. Update/Generate Plan
|
|
238
243
|
let plan: ResearchPlan;
|
|
@@ -254,7 +259,14 @@ export class DeepResearchOrchestrator {
|
|
|
254
259
|
});
|
|
255
260
|
} else {
|
|
256
261
|
const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
|
|
257
|
-
|
|
262
|
+
// Same once-per-round contract as round_start above: a 'wait' retry
|
|
263
|
+
// re-enters this branch for the SAME round, and re-emitting
|
|
264
|
+
// evaluation_start per retry gave consumers counting evaluations
|
|
265
|
+
// duplicates (2, 2, 2 for one round-2 evaluation). The progress
|
|
266
|
+
// pulse below is a status update and stays per-entry.
|
|
267
|
+
if (firstEntryThisRound) {
|
|
268
|
+
observer?.onEvaluationStart?.(this.currentRound);
|
|
269
|
+
}
|
|
258
270
|
observer?.onEvaluationProgress?.('evaluating');
|
|
259
271
|
plan = await planningService.updatePlanForRound({
|
|
260
272
|
sessionId: researchId,
|
|
@@ -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,
|
|
@@ -337,6 +356,13 @@ export class QuickResearchOrchestrator {
|
|
|
337
356
|
|
|
338
357
|
// Ensure CITED LINKS section is accurate and consistent
|
|
339
358
|
result = synthesisService.ensureCitedLinks(this.options.researchId, result);
|
|
359
|
+
// Snapshot BEFORE steering guidance is appended: parseCitations absorbs
|
|
360
|
+
// any text trailing the last CITED LINKS entry into that entry's
|
|
361
|
+
// Description (the same mechanism that forced the weak-grounding notice
|
|
362
|
+
// to sit BEFORE the header), so knowledge-store descriptions must be
|
|
363
|
+
// parsed from the pre-steering text or a steered quick run stores the
|
|
364
|
+
// steering block inside the final source's description.
|
|
365
|
+
const citationSource = result;
|
|
340
366
|
|
|
341
367
|
// Append steering guidance — only active (consumed by orchestrator) messages
|
|
342
368
|
const finalSteeringMessages = getActiveSteeringMessages(this.options.sessionId);
|
|
@@ -367,7 +393,7 @@ export class QuickResearchOrchestrator {
|
|
|
367
393
|
if (!writer) {
|
|
368
394
|
logger.debug('[QuickOrchestrator] Writer queue unavailable, skipping link descriptions');
|
|
369
395
|
} else {
|
|
370
|
-
const citations = parseCitations(
|
|
396
|
+
const citations = parseCitations(citationSource);
|
|
371
397
|
if (citations.length === 0) {
|
|
372
398
|
logger.warn('[QuickOrchestrator] Researcher produced no parseable CITED LINKS — no descriptions stored for this session');
|
|
373
399
|
}
|
|
@@ -209,8 +209,12 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
|
|
|
209
209
|
return result;
|
|
210
210
|
} catch (error) {
|
|
211
211
|
const researchDuration = Date.now() - researchStart;
|
|
212
|
-
|
|
213
|
-
|
|
212
|
+
// Label a user cancel 'cancelled', not 'error' — the orchestrators' own
|
|
213
|
+
// research_session_duration_ms one layer down already does, and a metrics
|
|
214
|
+
// consumer diffing the two layers read every quick-mode cancel as an error.
|
|
215
|
+
const status = signal?.aborted ? 'cancelled' : 'error';
|
|
216
|
+
metrics.observe('research_manager_latency_ms', researchDuration, { depth: String(depth), status, source: 'extension' });
|
|
217
|
+
metrics.increment('research_manager_requests_total', 1, { depth: String(depth), status, source: 'extension' });
|
|
214
218
|
throw error;
|
|
215
219
|
} finally {
|
|
216
220
|
// Always release the run-cap slot — on success, error, or abort (the
|
|
@@ -366,6 +370,15 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
|
|
|
366
370
|
|
|
367
371
|
const researchers = plan.researchers || [];
|
|
368
372
|
const active = new Set<Promise<void>>();
|
|
373
|
+
// Set just before each fast-stop throw below. A researcher wedged past the
|
|
374
|
+
// bounded abort/settle waits is ABANDONED by the stop — its promise settles
|
|
375
|
+
// later, after the run's single terminal callback has fired (and, on the
|
|
376
|
+
// tool path, after endResearchSession freed the session state). Its late
|
|
377
|
+
// failure must then be suppressed: recordResearcherFailure would re-create
|
|
378
|
+
// the just-freed session entry via getPiState's create-on-read (a
|
|
379
|
+
// process-lifetime leak on the SDK's random per-run sessionIds), and
|
|
380
|
+
// onResearcherFailure would violate the no-events-after-terminal contract.
|
|
381
|
+
let runTerminated = false;
|
|
369
382
|
// Honour MAX_CONCURRENT_RESEARCHERS — prevents resource spikes when a plan has many researchers.
|
|
370
383
|
const maxConcurrent: number = (orchestratorOptions.config as Config)?.MAX_CONCURRENT_RESEARCHERS ?? DEFAULTS.MAX_CONCURRENT_RESEARCHERS;
|
|
371
384
|
const maxFailedResearchers: number = (orchestratorOptions.config as Config)?.MAX_FAILED_RESEARCHERS ?? DEFAULTS.MAX_FAILED_RESEARCHERS;
|
|
@@ -406,6 +419,7 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
|
|
|
406
419
|
Promise.allSettled(active).then(() => undefined),
|
|
407
420
|
() => logger.debug('[ResearchOrchestrationService] Fast-stop: active researchers did not settle within bound; proceeding with stop'),
|
|
408
421
|
);
|
|
422
|
+
runTerminated = true;
|
|
409
423
|
throw createResearchStopError(sessionId, researchId);
|
|
410
424
|
}
|
|
411
425
|
|
|
@@ -467,6 +481,10 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
|
|
|
467
481
|
// researcher-executor.ts.
|
|
468
482
|
if (signal?.aborted || isAbortSentinel(errMsg) || container?.isDisposing || errMsg.includes('during container disposal')) {
|
|
469
483
|
logger.debug(`[ResearchOrchestrationService] Researcher ${id} cancelled (aborted).`);
|
|
484
|
+
} else if (runTerminated) {
|
|
485
|
+
// Abandoned-by-fast-stop researcher settling AFTER the run's
|
|
486
|
+
// terminal error — see the runTerminated declaration above.
|
|
487
|
+
logger.debug(`[ResearchOrchestrationService] Researcher ${id} settled after the run's fast-stop terminal error; suppressing post-terminal failure recording (${errMsg}).`);
|
|
470
488
|
} else {
|
|
471
489
|
logger.error(`[ResearchOrchestrationService] Researcher ${id} failed: ${errMsg}`);
|
|
472
490
|
// Record failure (with its root cause) for stopping logic and for the
|
|
@@ -525,6 +543,7 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
|
|
|
525
543
|
() => logger.debug('[ResearchOrchestrationService] Fast-stop: active researchers did not settle within bound; proceeding with stop'),
|
|
526
544
|
);
|
|
527
545
|
|
|
546
|
+
runTerminated = true;
|
|
528
547
|
throw createResearchStopError(sessionId, researchId);
|
|
529
548
|
}
|
|
530
549
|
}
|
|
@@ -34,6 +34,26 @@ 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 cited 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
|
+
// "cited", not "verified": since the provenance gate was removed (see the
|
|
49
|
+
// comment in extractGlobalCitations' caller), this count includes citations
|
|
50
|
+
// the model wrote whose pages were never successfully retrieved — claiming
|
|
51
|
+
// they were "verified" overstated what the pipeline checked.
|
|
52
|
+
return `_Grounding notice: only ${count} source${count === 1 ? ' is' : 's are'} cited in this report. ` +
|
|
53
|
+
'Claims not attributable to the cited source' + (count === 1 ? '' : 's') +
|
|
54
|
+
' above should be treated as unverified — cross-check anything load-bearing before relying on it._';
|
|
55
|
+
}
|
|
56
|
+
|
|
37
57
|
|
|
38
58
|
/**
|
|
39
59
|
* Redact inline `https?://` tokens in synthesis prose whose URL was NOT retrieved
|
|
@@ -382,6 +402,16 @@ export class ResearchSynthesisService implements IService {
|
|
|
382
402
|
// fabricated source can still reach the user; inline URLs also violate the
|
|
383
403
|
// required format. Verified inline references (in `trustedUrls`) are kept.
|
|
384
404
|
body = redactUnverifiedProseUrls(body, trustedUrls);
|
|
405
|
+
if (globalCitations.length < WEAK_GROUNDING_MIN_SOURCES) {
|
|
406
|
+
// The notice goes BEFORE the CITED LINKS header, never after it: anything
|
|
407
|
+
// trailing the last citation entry is absorbed into that entry's
|
|
408
|
+
// Description by parseCitations, and quick mode re-parses this exact
|
|
409
|
+
// string to build knowledge-store entries — a notice placed after the
|
|
410
|
+
// list polluted the stored description of the final source on precisely
|
|
411
|
+
// the degraded runs the notice exists to flag.
|
|
412
|
+
logger.warn(`[ResearchSynthesisService] Report is weakly grounded: only ${globalCitations.length} cited source(s) — appending grounding notice.`);
|
|
413
|
+
return `${body}\n\n${weakGroundingNote(globalCitations.length)}\n\n${verifiedLinksSection}`;
|
|
414
|
+
}
|
|
385
415
|
return `${body}\n\n${verifiedLinksSection}`;
|
|
386
416
|
}
|
|
387
417
|
logger.warn('[ResearchSynthesisService] Synthesis missing CITED LINKS - appending verified version');
|
|
@@ -393,6 +423,12 @@ export class ResearchSynthesisService implements IService {
|
|
|
393
423
|
const validIds = new Set(globalCitations.map((c) => c.id));
|
|
394
424
|
let prose = rewriteCitationMarkers(synthesis.trim(), new Map(), (n) => !validIds.has(n));
|
|
395
425
|
prose = redactUnverifiedProseUrls(prose, trustedUrls);
|
|
426
|
+
if (globalCitations.length < WEAK_GROUNDING_MIN_SOURCES) {
|
|
427
|
+
// Before the CITED LINKS header for the same parseCitations-absorption
|
|
428
|
+
// reason as the replace path above.
|
|
429
|
+
logger.warn(`[ResearchSynthesisService] Report is weakly grounded: only ${globalCitations.length} cited source(s) — appending grounding notice.`);
|
|
430
|
+
return `${prose}\n\n${weakGroundingNote(globalCitations.length)}\n\n${verifiedLinksSection}`;
|
|
431
|
+
}
|
|
396
432
|
return `${prose}\n\n${verifiedLinksSection}`;
|
|
397
433
|
}
|
|
398
434
|
|