@lincoln504/pi-research 1.5.0 → 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/dist/cli.mjs +210 -86
- package/dist/thread-worker.mjs +16 -3
- package/docs/CHANGELOG.md +87 -2
- package/docs/CONFIGURATION.md +1 -1
- package/package.json +3 -2
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +1 -1
- package/src/config.ts +5 -0
- package/src/core/interfaces/state-manager-interfaces.ts +8 -0
- package/src/core/planning-service.ts +27 -3
- package/src/infrastructure/browser/thread-worker-browser.ts +10 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +15 -2
- package/src/infrastructure/browser/thread-worker.mjs +16 -3
- package/src/infrastructure/embedding/embedding-factory.ts +34 -0
- package/src/infrastructure/embedding/embedding-server.ts +5 -0
- 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 +26 -4
- package/src/knowledge/writer-queue.ts +4 -2
- package/src/orchestration/deep-research-orchestrator.ts +15 -3
- package/src/orchestration/quick-research-orchestrator.ts +8 -1
- package/src/orchestration/research-orchestration-service.ts +21 -2
- package/src/orchestration/research-synthesis-service.ts +8 -4
- package/src/orchestration/researcher-executor.ts +15 -1
- package/src/orchestration/session-state.ts +17 -0
- package/src/sdk.ts +59 -52
- package/src/stackexchange/index.ts +8 -0
- package/src/tools/health-tool-definition.ts +11 -1
- package/src/tools/research-knowledge-search.ts +23 -8
- package/src/tools/stackexchange.ts +14 -7
- package/src/utils/llm-usage.ts +4 -1
- package/src/web-research/scraper-utils.ts +6 -2
- package/src/youtube/potoken.ts +20 -2
|
@@ -1571,6 +1571,8 @@ function isBenignScrapeFailure(error) {
|
|
|
1571
1571
|
msg.startsWith("Fetch returned stub:") || // nav-only / near-empty page
|
|
1572
1572
|
msg.startsWith("Could not extract content from PDF") || // malformed/encrypted/scanned-only PDF
|
|
1573
1573
|
msg.startsWith("PDF too large") || // exceeds the deliberate 100MB policy cap
|
|
1574
|
+
msg.startsWith("HTML response too large") || // fetch-layer 25MB policy cap — same class as the PDF cap
|
|
1575
|
+
msg.startsWith("Browser HTML too large") || // browser-layer 25MB policy cap
|
|
1574
1576
|
/^HTTP \d{3}\b/.test(msg) || // remote 4xx/5xx status
|
|
1575
1577
|
isCloudflareBlockError(error) || isTaskTimeoutError(error) || isPoolShutdownError(error);
|
|
1576
1578
|
}
|
|
@@ -2190,7 +2192,7 @@ async function extractSearchResults(page) {
|
|
|
2190
2192
|
try {
|
|
2191
2193
|
const u = new URL(url);
|
|
2192
2194
|
const uddg = u.searchParams.get("uddg");
|
|
2193
|
-
if (uddg) url =
|
|
2195
|
+
if (uddg) url = uddg;
|
|
2194
2196
|
} catch {
|
|
2195
2197
|
}
|
|
2196
2198
|
let urlHostname = "";
|
|
@@ -2374,7 +2376,8 @@ async function executeScrapeTask(_context, url, signal) {
|
|
|
2374
2376
|
}
|
|
2375
2377
|
const contentType = await response?.headerValue("content-type") || "";
|
|
2376
2378
|
const status = response?.status?.() ?? 0;
|
|
2377
|
-
|
|
2379
|
+
const finalUrl = String(response?.url?.() ?? url);
|
|
2380
|
+
if (contentType.includes("application/pdf") || finalUrl.toLowerCase().endsWith(".pdf")) {
|
|
2378
2381
|
if (!response) throw new Error(`[Worker] No response received for PDF URL: ${url}`);
|
|
2379
2382
|
if (status >= 400) throw new Error(`HTTP ${status}`);
|
|
2380
2383
|
const declaredLength = parseInt(await response.headerValue("content-length").catch(() => null) || "", 10);
|
|
@@ -2805,7 +2808,17 @@ async function initBrowser() {
|
|
|
2805
2808
|
// silent either, so the mute is applied unconditionally.
|
|
2806
2809
|
firefox_user_prefs: {
|
|
2807
2810
|
"media.volume_scale": "0.0",
|
|
2808
|
-
"media.autoplay.default": 5
|
|
2811
|
+
"media.autoplay.default": 5,
|
|
2812
|
+
// SSRF: WebSocket connections bypass BOTH layers of the browser
|
|
2813
|
+
// scrape defense — page.route('**') intercepts HTTP(S) only, and
|
|
2814
|
+
// the serverAddr() rebinding backstop hangs off page.on('response'),
|
|
2815
|
+
// which a WS handshake never fires. A hostile scraped page could
|
|
2816
|
+
// therefore `new WebSocket('ws://10.x.x.x/...')` to probe or reach
|
|
2817
|
+
// internal hosts with no validation, and write any reply into the
|
|
2818
|
+
// DOM that page.content() returns. The scraper extracts static
|
|
2819
|
+
// content (domcontentloaded), so nothing it needs arrives over WS —
|
|
2820
|
+
// disable the channel outright at the engine level.
|
|
2821
|
+
"network.websocket.max-connections": 0
|
|
2809
2822
|
}
|
|
2810
2823
|
});
|
|
2811
2824
|
launchPromise.catch((err) => logToDebugFile3("DEBUG", `[ThreadWorker] Background browser launch rejection: ${err.message}`));
|
|
@@ -162,6 +162,31 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
|
|
|
162
162
|
// read → the entry omits it and liveness falls back to a bare signal-0 check.
|
|
163
163
|
const myStartTime = await processLifecycle.getCurrentProcessStartTime();
|
|
164
164
|
|
|
165
|
+
// Read-only state (a NEWER build's state file is on disk — mixed-version
|
|
166
|
+
// window): every state write is suppressed, so the port=-1 candidacy claim
|
|
167
|
+
// below would never be visible to other processes, every process would
|
|
168
|
+
// "win" its own election, and the GPU lock would likewise always read as
|
|
169
|
+
// unowned — the exact concurrent-GPU-init segfault class election + lock
|
|
170
|
+
// exist to prevent. Degrade to a coordination-free mode instead: no
|
|
171
|
+
// election, local in-process embedder forced onto CPU (concurrent CPU
|
|
172
|
+
// inference across processes is safe; the exclusion protects GPU/WebGPU
|
|
173
|
+
// contexts, and a CPU device never touches the GPU lock). Slower embedding
|
|
174
|
+
// for the mixed-version window only; normal service resumes once every
|
|
175
|
+
// session runs the same build.
|
|
176
|
+
const localCpuForReadOnlyState = (): IEmbedder => {
|
|
177
|
+
logger.warn(
|
|
178
|
+
'[EmbeddingFactory] State file is read-only (written by a newer build): cross-process leader election and the GPU lock cannot coordinate in this mode. Skipping election and embedding locally on CPU until all sessions run the same build.',
|
|
179
|
+
);
|
|
180
|
+
const embedder = buildInProcessEmbedder({ ...cfg, EMBEDDING_DEVICE: 'cpu' }, stateManager);
|
|
181
|
+
_embeddingInstance = embedder;
|
|
182
|
+
_cachedModel = cfg.EMBEDDING_MODEL;
|
|
183
|
+
_cachedDevice = cfg.EMBEDDING_DEVICE;
|
|
184
|
+
return embedder;
|
|
185
|
+
};
|
|
186
|
+
if (stateManager.isReadOnly?.()) {
|
|
187
|
+
return localCpuForReadOnlyState();
|
|
188
|
+
}
|
|
189
|
+
|
|
165
190
|
// ---- Phase 1: Atomically claim candidacy under the state lock ----
|
|
166
191
|
// Writing port=-1 acts as a mutex: other processes that see it will wait
|
|
167
192
|
// for the real port rather than starting their own model initialization.
|
|
@@ -200,6 +225,15 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
|
|
|
200
225
|
return state;
|
|
201
226
|
});
|
|
202
227
|
|
|
228
|
+
// Re-check AFTER the claim: the read-only flag is set lazily by the first
|
|
229
|
+
// read that encounters the newer file — which can be this very updateState.
|
|
230
|
+
// In that case iAmCandidate is true but the claim write was silently
|
|
231
|
+
// suppressed: no other process can see it, so proceeding as leader is the
|
|
232
|
+
// concurrent-election hazard the pre-check above exists to avoid.
|
|
233
|
+
if (iAmCandidate && stateManager.isReadOnly?.()) {
|
|
234
|
+
return localCpuForReadOnlyState();
|
|
235
|
+
}
|
|
236
|
+
|
|
203
237
|
// A live leader serving a DIFFERENT model must not be adopted: two sessions
|
|
204
238
|
// with different EMBEDDING_MODELs would cross-embed (same-dimension models
|
|
205
239
|
// corrupt the vector space silently). Fall back to a local in-process
|
|
@@ -350,6 +350,11 @@ export class EmbeddingServer implements IEmbedder {
|
|
|
350
350
|
// stuck native thread (itself hanging) or touch a device-lost context. Clearing
|
|
351
351
|
// the state registration + closing the HTTP server is enough for re-election;
|
|
352
352
|
// the wedged native resources are abandoned to eventual process exit.
|
|
353
|
+
// Latch the wedge on the Embedder itself too: it stays registered for
|
|
354
|
+
// process-exit cleanup (registerGlobalEmbedder), and without the latch that
|
|
355
|
+
// exit task's dispose() would call pipeline.dispose() on the wedged session
|
|
356
|
+
// anyway — re-creating at teardown the exact hang this path avoids.
|
|
357
|
+
this.embedder.markPipelineWedged();
|
|
353
358
|
void this.shutdown(false);
|
|
354
359
|
this.externalOnPoison?.(err);
|
|
355
360
|
}
|
|
@@ -198,6 +198,21 @@ export class StateManager {
|
|
|
198
198
|
};
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
/**
|
|
202
|
+
* True once this process has seen a newer-build state file and entered
|
|
203
|
+
* read-only mode (writes suppressed, reads served from an in-memory default).
|
|
204
|
+
* In this mode NO cross-process coordination that lives in the state file
|
|
205
|
+
* works: a GPU-lock acquire always sees "no owner" and the embedding
|
|
206
|
+
* leader-election CAS always sees an empty slot, so every read-only process
|
|
207
|
+
* would self-elect — the concurrent-GPU-init class those mechanisms exist to
|
|
208
|
+
* prevent. Consumers that rely on that exclusion (the embedding factory) must
|
|
209
|
+
* check this and degrade to a coordination-free mode (local CPU embedding).
|
|
210
|
+
* The flag is set lazily by the first read that encounters the newer file.
|
|
211
|
+
*/
|
|
212
|
+
isReadOnly(): boolean {
|
|
213
|
+
return this.stateTooNew;
|
|
214
|
+
}
|
|
215
|
+
|
|
201
216
|
// ==================== Core State Operations ====================
|
|
202
217
|
|
|
203
218
|
/**
|
|
@@ -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]!),
|
|
@@ -1007,11 +1016,24 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
1007
1016
|
// coexist, and LanceDB's fragment-scan order typically returns the OLDEST row
|
|
1008
1017
|
// first — limit(1) would serve the superseded content (and feed its older
|
|
1009
1018
|
// timestamp to the serve-age gate) until the next successful ingest.
|
|
1010
|
-
const
|
|
1019
|
+
const whereClause = `url = '${escapedUrl}' AND ingestion_type = 'synthesis-description' AND (${scopeFilter})`;
|
|
1020
|
+
let results = await table
|
|
1011
1021
|
.query()
|
|
1012
|
-
.where(
|
|
1022
|
+
.where(whereClause)
|
|
1013
1023
|
.limit(16)
|
|
1014
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
|
+
}
|
|
1015
1037
|
|
|
1016
1038
|
const duration = Date.now() - startTime;
|
|
1017
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.
|
|
@@ -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,
|
|
@@ -356,6 +356,13 @@ export class QuickResearchOrchestrator {
|
|
|
356
356
|
|
|
357
357
|
// Ensure CITED LINKS section is accurate and consistent
|
|
358
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;
|
|
359
366
|
|
|
360
367
|
// Append steering guidance — only active (consumed by orchestrator) messages
|
|
361
368
|
const finalSteeringMessages = getActiveSteeringMessages(this.options.sessionId);
|
|
@@ -386,7 +393,7 @@ export class QuickResearchOrchestrator {
|
|
|
386
393
|
if (!writer) {
|
|
387
394
|
logger.debug('[QuickOrchestrator] Writer queue unavailable, skipping link descriptions');
|
|
388
395
|
} else {
|
|
389
|
-
const citations = parseCitations(
|
|
396
|
+
const citations = parseCitations(citationSource);
|
|
390
397
|
if (citations.length === 0) {
|
|
391
398
|
logger.warn('[QuickOrchestrator] Researcher produced no parseable CITED LINKS — no descriptions stored for this session');
|
|
392
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,7 +34,7 @@ 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
|
|
37
|
+
// Below this many cited sources, a completed report is WEAKLY grounded: it will
|
|
38
38
|
// still read as a fully sourced document, but almost none of its claims can trace to
|
|
39
39
|
// retrieved content — exactly the shape of a run where scrapes mostly failed (bot
|
|
40
40
|
// blocks, resource starvation) or the topic had little real coverage, and the model
|
|
@@ -45,7 +45,11 @@ const UNVERIFIED_LINKS_NOTE =
|
|
|
45
45
|
const WEAK_GROUNDING_MIN_SOURCES = 3;
|
|
46
46
|
|
|
47
47
|
function weakGroundingNote(count: number): string {
|
|
48
|
-
|
|
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. ` +
|
|
49
53
|
'Claims not attributable to the cited source' + (count === 1 ? '' : 's') +
|
|
50
54
|
' above should be treated as unverified — cross-check anything load-bearing before relying on it._';
|
|
51
55
|
}
|
|
@@ -405,7 +409,7 @@ export class ResearchSynthesisService implements IService {
|
|
|
405
409
|
// string to build knowledge-store entries — a notice placed after the
|
|
406
410
|
// list polluted the stored description of the final source on precisely
|
|
407
411
|
// the degraded runs the notice exists to flag.
|
|
408
|
-
logger.warn(`[ResearchSynthesisService] Report is weakly grounded: only ${globalCitations.length}
|
|
412
|
+
logger.warn(`[ResearchSynthesisService] Report is weakly grounded: only ${globalCitations.length} cited source(s) — appending grounding notice.`);
|
|
409
413
|
return `${body}\n\n${weakGroundingNote(globalCitations.length)}\n\n${verifiedLinksSection}`;
|
|
410
414
|
}
|
|
411
415
|
return `${body}\n\n${verifiedLinksSection}`;
|
|
@@ -422,7 +426,7 @@ export class ResearchSynthesisService implements IService {
|
|
|
422
426
|
if (globalCitations.length < WEAK_GROUNDING_MIN_SOURCES) {
|
|
423
427
|
// Before the CITED LINKS header for the same parseCitations-absorption
|
|
424
428
|
// reason as the replace path above.
|
|
425
|
-
logger.warn(`[ResearchSynthesisService] Report is weakly grounded: only ${globalCitations.length}
|
|
429
|
+
logger.warn(`[ResearchSynthesisService] Report is weakly grounded: only ${globalCitations.length} cited source(s) — appending grounding notice.`);
|
|
426
430
|
return `${prose}\n\n${weakGroundingNote(globalCitations.length)}\n\n${verifiedLinksSection}`;
|
|
427
431
|
}
|
|
428
432
|
return `${prose}\n\n${verifiedLinksSection}`;
|
|
@@ -130,7 +130,21 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
|
|
|
130
130
|
if (signal?.aborted || container?.isDisposing || stopRequested()) break;
|
|
131
131
|
const delay = Math.min(1000 * Math.pow(2, linkAttempt - 2), config.RESEARCHER_MAX_RETRY_DELAY_MS);
|
|
132
132
|
logger.warn(`[ResearcherExecutor] Researcher ${id} has no initial search results; retry ${linkAttempt - 1}/${config.RESEARCHER_MAX_RETRIES} after ${delay}ms`);
|
|
133
|
-
|
|
133
|
+
// Abortable, like every sibling sleep in this file (attempt backoff
|
|
134
|
+
// below, stagger in runResearchers): a plain setTimeout held a cancel
|
|
135
|
+
// hostage for up to RESEARCHER_MAX_RETRY_DELAY_MS. The loop-top
|
|
136
|
+
// abort/stop check right after this resolves handles the early wake.
|
|
137
|
+
await new Promise<void>((resolve) => {
|
|
138
|
+
const timer = setTimeout(() => {
|
|
139
|
+
if (signal) signal.removeEventListener('abort', onAbort);
|
|
140
|
+
resolve();
|
|
141
|
+
}, delay);
|
|
142
|
+
const onAbort = () => { clearTimeout(timer); resolve(); };
|
|
143
|
+
if (signal) {
|
|
144
|
+
if (signal.aborted) onAbort();
|
|
145
|
+
else signal.addEventListener('abort', onAbort, { once: true });
|
|
146
|
+
}
|
|
147
|
+
});
|
|
134
148
|
}
|
|
135
149
|
if (signal?.aborted || container?.isDisposing || stopRequested()) break;
|
|
136
150
|
try {
|
|
@@ -130,6 +130,23 @@ export function addSteeringMessage(piSessionId: string | undefined, message: str
|
|
|
130
130
|
state.steeringMessages.splice(evictIdx, 1);
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
|
+
|
|
134
|
+
// 'popped' entries are otherwise pruned only in endResearchSession's
|
|
135
|
+
// last-run-out branch, which a long-lived pi session that always has at
|
|
136
|
+
// least one active run never reaches — every Alt+P pop then accumulated one
|
|
137
|
+
// array entry forever. Bound them to the same cap here; nothing reads a
|
|
138
|
+
// popped message once it is popped (dedup and eviction above both skip them).
|
|
139
|
+
const poppedCount = state.steeringMessages.filter(m => m.status === 'popped').length;
|
|
140
|
+
if (poppedCount > MAX_STEERING_MESSAGES) {
|
|
141
|
+
let toDrop = poppedCount - MAX_STEERING_MESSAGES;
|
|
142
|
+
state.steeringMessages = state.steeringMessages.filter(m => {
|
|
143
|
+
if (m.status === 'popped' && toDrop > 0) {
|
|
144
|
+
toDrop--;
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
return true;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
133
150
|
|
|
134
151
|
const steeringMsg: SteeringMessage = {
|
|
135
152
|
id: randomUUID(),
|