@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.
Files changed (56) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.mjs +396 -125
  3. package/dist/prompts/research-knowledge-relevance-triage.md +2 -0
  4. package/dist/prompts/research-knowledge-search-extractor.md +2 -0
  5. package/dist/prompts/researcher.md +2 -14
  6. package/dist/thread-worker.mjs +17 -4
  7. package/docs/AGENT-SKILL.md +4 -3
  8. package/docs/CHANGELOG.md +131 -2
  9. package/docs/CONFIGURATION.md +2 -2
  10. package/docs/SDK.md +1 -1
  11. package/package.json +3 -2
  12. package/skills/pi-research/SKILL.md +1 -1
  13. package/src/cli.ts +44 -3
  14. package/src/config.ts +31 -9
  15. package/src/constants.ts +23 -4
  16. package/src/core/interfaces/state-manager-interfaces.ts +8 -0
  17. package/src/core/llm/research-model-resolver.ts +38 -0
  18. package/src/core/planning-service.ts +39 -4
  19. package/src/index.ts +3 -1
  20. package/src/infrastructure/browser/thread-worker-browser.ts +10 -0
  21. package/src/infrastructure/browser/thread-worker-messaging.ts +20 -3
  22. package/src/infrastructure/browser/thread-worker.mjs +17 -4
  23. package/src/infrastructure/embedding/embedding-factory.ts +34 -0
  24. package/src/infrastructure/embedding/embedding-server.ts +34 -9
  25. package/src/infrastructure/state/state-manager.ts +15 -0
  26. package/src/knowledge/embedder.ts +36 -7
  27. package/src/knowledge/store-operations.ts +47 -38
  28. package/src/knowledge/store.ts +86 -8
  29. package/src/knowledge/writer-queue.ts +4 -2
  30. package/src/observers/research-observer-impl.ts +14 -6
  31. package/src/orchestration/deep-research-orchestrator.ts +15 -3
  32. package/src/orchestration/quick-research-orchestrator.ts +37 -11
  33. package/src/orchestration/research-orchestration-service.ts +21 -2
  34. package/src/orchestration/research-synthesis-service.ts +36 -0
  35. package/src/orchestration/researcher-executor.ts +46 -17
  36. package/src/orchestration/researcher.ts +2 -2
  37. package/src/orchestration/session-state.ts +36 -4
  38. package/src/prompts/research-knowledge-relevance-triage.md +2 -0
  39. package/src/prompts/research-knowledge-search-extractor.md +2 -0
  40. package/src/prompts/researcher.md +2 -14
  41. package/src/sdk.ts +65 -52
  42. package/src/stackexchange/index.ts +8 -0
  43. package/src/tools/health-tool-definition.ts +11 -1
  44. package/src/tools/index.ts +2 -1
  45. package/src/tools/research-knowledge-search.ts +58 -10
  46. package/src/tools/research-tool-definition.ts +27 -16
  47. package/src/tools/scrape.ts +8 -1
  48. package/src/tools/stackexchange.ts +14 -7
  49. package/src/utils/llm-usage.ts +4 -1
  50. package/src/utils/log-utils.ts +5 -2
  51. package/src/utils/metrics-summary.ts +21 -1
  52. package/src/utils/tool-usage-tracker.ts +6 -3
  53. package/src/web-research/scraper-utils.ts +6 -2
  54. package/src/web-research/web-scraper.ts +9 -0
  55. package/src/youtube/index.ts +11 -1
  56. 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
- try {
713
- if (typeof (this.pipeline as any).dispose === 'function') {
714
- await (this.pipeline as DisposablePipeline).dispose();
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
- } catch (err) {
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]!,
@@ -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
- const vectors = await this.options.embedder.embedMany(texts);
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
- await fsPromises.rename(canonicalDir, tempDir);
531
- this.tableName = tempTableName;
532
- this.table = await this.db.openTable(this.tableName);
533
- logger.info(`[store] Migrated data remains safe and accessible as ${tempTableName}; canonical rename deferred to a future successful manifest save.`);
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 results = await table
1019
+ const whereClause = `url = '${escapedUrl}' AND ingestion_type = 'synthesis-description' AND (${scopeFilter})`;
1020
+ let results = await table
955
1021
  .query()
956
- .where(`url = '${escapedUrl}' AND ingestion_type = 'synthesis-description' AND (${scopeFilter})`)
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
- // current project's visible scope (local workspace + global entries).
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
- if (this.currentRound > lastAnnouncedRound) {
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
- await orchestrationService.checkHealth(this.currentRound, researchId, ctx);
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
- observer?.onEvaluationStart?.(this.currentRound);
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
- const maxScrapeBatches = getMaxScrapeBatches(this.config);
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
- logger.debug(`[QuickOrchestrator] System Prompt:\n${prompt}`);
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(query);
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(result);
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
- metrics.observe('research_manager_latency_ms', researchDuration, { depth: String(depth), status: 'error', source: 'extension' });
213
- metrics.increment('research_manager_requests_total', 1, { depth: String(depth), status: 'error', source: 'extension' });
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