@lincoln504/pi-research 1.3.11 → 1.4.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 +2 -2
- package/dist/cli.mjs +5558 -4596
- package/dist/prompts/research-tool-usage.md +1 -1
- package/dist/prompts/researcher.md +1 -0
- package/dist/prompts/system-coordinator.md +1 -1
- package/dist/prompts/system-lead-router.md +122 -0
- package/dist/prompts/system-lead-synthesizer.md +46 -0
- package/dist/thread-worker.mjs +123 -30
- package/docs/ARCHITECTURE.md +69 -24
- package/docs/CHANGELOG.md +215 -0
- package/docs/CONFIGURATION.md +108 -8
- package/package.json +19 -11
- package/scripts/build.cjs +8 -1
- package/scripts/cleanup.cjs +26 -5
- package/scripts/setup.cjs +25 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +41 -2
- package/src/config.ts +12 -8
- package/src/constants.ts +1 -1
- package/src/core/interfaces/observer-interfaces.ts +3 -3
- package/src/core/interfaces/orchestration-interfaces.ts +2 -0
- package/src/core/interfaces/planning-interfaces.ts +7 -1
- package/src/core/interfaces/scheduler-interfaces.ts +18 -2
- package/src/core/llm/agentic-repair.ts +7 -2
- package/src/core/llm/inject-date.ts +4 -1
- package/src/core/llm/llm-utils.ts +7 -0
- package/src/core/planning-service.ts +719 -91
- package/src/core/planning-utils.ts +21 -5
- package/src/core/service-initialization.ts +19 -3
- package/src/core/service-registry.ts +19 -3
- package/src/healthcheck/index.ts +11 -2
- package/src/index.ts +4 -3
- package/src/infrastructure/browser/browser-client.ts +68 -34
- package/src/infrastructure/browser/browser-task-scheduler.ts +220 -34
- package/src/infrastructure/browser/config.ts +77 -6
- package/src/infrastructure/browser/priority-task-queue.ts +55 -1
- package/src/infrastructure/browser/scheduler-factory.ts +12 -1
- package/src/infrastructure/browser/task-execution-service.ts +17 -7
- package/src/infrastructure/browser/thread-worker-browser.ts +66 -20
- package/src/infrastructure/browser/thread-worker-messaging.ts +38 -6
- package/src/infrastructure/browser/thread-worker.mjs +123 -30
- package/src/infrastructure/browser/thread-worker.ts +2 -2
- package/src/infrastructure/browser/worker-pool-manager.ts +127 -12
- package/src/infrastructure/embedding/embedding-client.ts +14 -4
- package/src/infrastructure/embedding/embedding-factory.ts +83 -15
- package/src/infrastructure/file-lock-service.ts +214 -19
- package/src/infrastructure/gpu-resource-service.ts +15 -1
- package/src/infrastructure/knowledge-store-service.ts +14 -0
- package/src/infrastructure/research-run-semaphore.ts +1 -1
- package/src/infrastructure/service-initialization.ts +7 -0
- package/src/knowledge/embedder-init.ts +60 -18
- package/src/knowledge/store.ts +11 -5
- package/src/logger.ts +11 -0
- package/src/orchestration/deep-research-orchestrator.ts +141 -87
- package/src/orchestration/quick-research-orchestrator.ts +11 -2
- package/src/orchestration/research-orchestration-service.ts +30 -1
- package/src/orchestration/research-synthesis-service.ts +53 -1
- package/src/orchestration/researcher-executor.ts +53 -2
- package/src/orchestration/researcher.ts +15 -2
- package/src/prompts/research-tool-usage.md +1 -1
- package/src/prompts/researcher.md +1 -0
- package/src/prompts/system-coordinator.md +1 -1
- package/src/prompts/system-lead-router.md +122 -0
- package/src/prompts/system-lead-synthesizer.md +46 -0
- package/src/sdk.ts +10 -1
- package/src/security/nvd-client.ts +55 -4
- package/src/security/osv-client.ts +13 -1
- package/src/tools/research-knowledge-search.ts +29 -4
- package/src/tools/scrape.ts +6 -0
- package/src/tools/search.ts +7 -0
- package/src/tools/security.ts +27 -9
- package/src/tools/stackexchange.ts +13 -7
- package/src/tools/youtube-transcript.ts +26 -12
- package/src/utils/cancellation.ts +36 -0
- package/src/utils/coverage-digest.ts +204 -0
- package/src/utils/llm-usage.ts +33 -6
- package/src/utils/log-utils.ts +16 -1
- package/src/utils/metrics-summary.ts +20 -4
- package/src/utils/metrics.ts +27 -1
- package/src/web-research/browser-search.ts +93 -20
- package/src/web-research/scraper-utils.ts +55 -14
- package/dist/prompts/system-lead-evaluator.md +0 -118
- package/src/prompts/system-lead-evaluator.md +0 -118
|
@@ -159,7 +159,17 @@ export class DeepResearchOrchestrator {
|
|
|
159
159
|
|
|
160
160
|
const MAX_WAIT_RETRIES = 5;
|
|
161
161
|
let waitRetryCount = 0;
|
|
162
|
-
|
|
162
|
+
// Whether the ROUTER chose to finish inside the loop, as opposed to the loop ending
|
|
163
|
+
// because the round budget ran out. The router never carries the report: it is asked
|
|
164
|
+
// for a decision, is given no corpus to write from (it sees each report once, on the
|
|
165
|
+
// round it arrives, and digests thereafter), and any content it returns anyway is
|
|
166
|
+
// discarded in planning-service. So this only records which UI and observer events
|
|
167
|
+
// already fired, not a result to reuse. Final synthesis runs either way, below.
|
|
168
|
+
let routerChoseSynthesis = false;
|
|
169
|
+
// Highest round number handed to observer.onRoundStart. The final synthesis
|
|
170
|
+
// announces itself too, and it must not repeat or skip: `round_start` is an SDK
|
|
171
|
+
// event and a consumer counting rounds reads the gaps.
|
|
172
|
+
let lastAnnouncedRound = 0;
|
|
163
173
|
|
|
164
174
|
try {
|
|
165
175
|
while (this.currentRound < maxRounds) {
|
|
@@ -190,11 +200,36 @@ export class DeepResearchOrchestrator {
|
|
|
190
200
|
}
|
|
191
201
|
}
|
|
192
202
|
|
|
203
|
+
// The final iteration synthesizes; it never researches. Leave BEFORE announcing
|
|
204
|
+
// it, because announcing it is the whole problem: the log said `Round 2/2`, the
|
|
205
|
+
// observer fired onRoundStart (which arms the panel's deferred clear, consumed
|
|
206
|
+
// by a researcher start that never comes), the SDK emitted a `round_start`
|
|
207
|
+
// event, and a full infrastructure health check ran — 105 seconds of it on one
|
|
208
|
+
// measured run — for a round in which no researcher was ever dispatched. 92 of
|
|
209
|
+
// 93 capped runs in the retained logs announced a round that did nothing.
|
|
210
|
+
//
|
|
211
|
+
// This must sit AFTER the steering block above: a message consumed at the top of
|
|
212
|
+
// the final iteration raises maxRounds and legitimately turns this into a real
|
|
213
|
+
// research round. It must also not fire on round 1, which researches even when
|
|
214
|
+
// the budget is 1. The increment stays — the forced synthesis below is told
|
|
215
|
+
// which round it is synthesizing at.
|
|
216
|
+
if (this.currentRound > 1 && this.currentRound >= maxRounds) {
|
|
217
|
+
logger.log(`[DeepOrchestrator] Round cap (${maxRounds}) reached — skipping the evaluator and going straight to final synthesis ${this.elapsed()}`);
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
|
|
193
221
|
const roundLabel = this.currentRound > baseMaxRounds
|
|
194
222
|
? `Round ${this.currentRound}/${maxRounds} (extra, steering-driven, base=${baseMaxRounds})`
|
|
195
223
|
: `Round ${this.currentRound}/${maxRounds}`;
|
|
196
224
|
logger.log(`[DeepOrchestrator] ${roundLabel} ${this.elapsed()}`);
|
|
197
|
-
|
|
225
|
+
// Guarded, because a 'wait' decision decrements the round and re-enters the loop
|
|
226
|
+
// to run the SAME round again. Announcing it each time emitted `round_start`
|
|
227
|
+
// 1, 2, 2, 3 — a duplicate rather than the gap the post-loop guard was added for,
|
|
228
|
+
// but the same broken contract for a consumer counting rounds.
|
|
229
|
+
if (this.currentRound > lastAnnouncedRound) {
|
|
230
|
+
observer?.onRoundStart?.(this.currentRound);
|
|
231
|
+
lastAnnouncedRound = this.currentRound;
|
|
232
|
+
}
|
|
198
233
|
|
|
199
234
|
// Check infrastructure health (advisory: logs status, never aborts the run).
|
|
200
235
|
await orchestrationService.checkHealth(this.currentRound, researchId, ctx);
|
|
@@ -217,17 +252,6 @@ export class DeepResearchOrchestrator {
|
|
|
217
252
|
excludeTools: this.options.excludeTools,
|
|
218
253
|
steeringMessages: steeringTexts,
|
|
219
254
|
});
|
|
220
|
-
} else if (this.currentRound >= maxRounds) {
|
|
221
|
-
// Last iteration: the `plan.action === 'synthesize' || currentRound >=
|
|
222
|
-
// maxRounds` break below fires no matter what the evaluator decides, and
|
|
223
|
-
// the forced-synthesis path after the loop runs updatePlanForRound again
|
|
224
|
-
// with mustSynthesize. So on every capped run whose evaluator would have
|
|
225
|
-
// said `delegate` — the common case, since it is being cut off mid-plan —
|
|
226
|
-
// this call is a second full-context coordinator request whose result is
|
|
227
|
-
// discarded. Break straight to the forced synthesis: exactly one
|
|
228
|
-
// coordinator call either way, and the same outcome.
|
|
229
|
-
logger.log(`[DeepOrchestrator] Round cap (${maxRounds}) reached — skipping the evaluator and going straight to final synthesis ${this.elapsed()}`);
|
|
230
|
-
break;
|
|
231
255
|
} else {
|
|
232
256
|
const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
|
|
233
257
|
observer?.onEvaluationStart?.(this.currentRound);
|
|
@@ -241,7 +265,12 @@ export class DeepResearchOrchestrator {
|
|
|
241
265
|
modelRegistry: ctx.modelRegistry,
|
|
242
266
|
cwd: ctx.cwd,
|
|
243
267
|
config: this.config,
|
|
268
|
+
// Both are used by routing, for different rounds: the bodies of the reports
|
|
269
|
+
// that arrived THIS round are sent in full, and every earlier researcher is
|
|
270
|
+
// represented by its digest alone. Passing reports also lets a caller-side
|
|
271
|
+
// digest miss fall back to deriving one from the body.
|
|
244
272
|
reports: synthesisService.getAllReports(researchId),
|
|
273
|
+
digests: synthesisService.getAllDigests(researchId),
|
|
245
274
|
previousPlan: planningService.getCurrentPlan(researchId),
|
|
246
275
|
totalResearchersPlanned: planningService.getTotalResearchersPlanned(researchId),
|
|
247
276
|
// Live, steering-extended budget — see maxRounds comment above. Without
|
|
@@ -271,7 +300,12 @@ export class DeepResearchOrchestrator {
|
|
|
271
300
|
if (plan.action === 'synthesize' || this.currentRound >= maxRounds) {
|
|
272
301
|
logger.log(`[DeepOrchestrator] Synthesis phase reached at Round ${this.currentRound} ${this.elapsed()}`);
|
|
273
302
|
if (plan.action === 'synthesize') {
|
|
274
|
-
|
|
303
|
+
// Only the ROUTER (round 2+) can reach this: generatePlan pins round 1 to
|
|
304
|
+
// 'delegate' so a coordinator can never end the run before any source is
|
|
305
|
+
// read. The router's decision ends the loop, but it has never held the
|
|
306
|
+
// whole corpus — only this round's reports and earlier digests — so the
|
|
307
|
+
// report itself is written by the terminal synthesis below.
|
|
308
|
+
routerChoseSynthesis = true;
|
|
275
309
|
observer?.onEvaluationDecision?.('synthesize', plan, this.currentRound);
|
|
276
310
|
}
|
|
277
311
|
break;
|
|
@@ -426,22 +460,71 @@ export class DeepResearchOrchestrator {
|
|
|
426
460
|
|
|
427
461
|
// 5. Evaluation Phase
|
|
428
462
|
observer?.onEvaluationProgress?.('evaluating');
|
|
463
|
+
|
|
464
|
+
// Counted at the END of a round that actually researched, which is what the
|
|
465
|
+
// run summary means by "rounds". The counter it used to read
|
|
466
|
+
// (`evaluator_runs_total`) lost its emit site in c90d7f37 and nothing noticed,
|
|
467
|
+
// because the only consumer gates on `> 1` — so a value permanently stuck at 0
|
|
468
|
+
// is indistinguishable from a single-round run, and the "N rounds" line has
|
|
469
|
+
// never rendered since.
|
|
470
|
+
metrics.increment('research_rounds_completed_total', 1, { mode: 'deep', complexity: String(complexity) });
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Final Synthesis — always its own call. Routing and synthesis are separate jobs:
|
|
474
|
+
// no router call ever holds the whole corpus (it reads each report on the single
|
|
475
|
+
// round it arrives, and digests after that), so none of them can produce the report
|
|
476
|
+
// and there is nothing from the loop to reuse. This costs one extra call on runs the
|
|
477
|
+
// router ends early, and in exchange removes a full re-send of the corpus from every
|
|
478
|
+
// round that preceded it.
|
|
479
|
+
//
|
|
480
|
+
// ZERO reports collected AND researchers actually failed — the run attempted
|
|
481
|
+
// research and produced nothing groundable. Checked BEFORE the synthesis call
|
|
482
|
+
// rather than after it: with every researcher failed (exactly the insufficient-
|
|
483
|
+
// credit class the researcher classifier exists for) the corpus is empty, so the
|
|
484
|
+
// call is billed over nothing and its result is then discarded by this very gate.
|
|
485
|
+
// The verdict does not depend on the synthesis text — it depends on whether there
|
|
486
|
+
// was any source material — so nothing is lost by asking first.
|
|
487
|
+
//
|
|
488
|
+
// Gated on failures, not merely on the absence of reports: a run cancelled before
|
|
489
|
+
// any researcher finished has no reports and no failures, and must not be reported
|
|
490
|
+
// as having produced nothing groundable.
|
|
491
|
+
const synthesisCheckService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
|
|
492
|
+
const hasReports = synthesisCheckService.hasReports(researchId);
|
|
493
|
+
const failedIds = getFailedResearchers(this.options.sessionId, researchId);
|
|
494
|
+
if (!hasReports && failedIds.length > 0) {
|
|
495
|
+
const reasons = getResearcherFailureReasons(this.options.sessionId, researchId);
|
|
496
|
+
const causes = failedIds
|
|
497
|
+
.map((id) => `researcher ${id}: ${reasons[id] ?? 'no usable report'}`)
|
|
498
|
+
.join('; ');
|
|
499
|
+
throw new Error(
|
|
500
|
+
`Research produced no report — no source material was collected. Causes: ${causes}`
|
|
501
|
+
);
|
|
429
502
|
}
|
|
430
503
|
|
|
431
|
-
// Final Synthesis — reuse the plan from the loop if the evaluator already
|
|
432
|
-
// decided to synthesize (avoids a second full LLM call). Only call the
|
|
433
|
-
// evaluator again when the loop ended because maxRounds was hit without
|
|
434
|
-
// the evaluator explicitly choosing to synthesize.
|
|
435
504
|
logger.log(`[DeepOrchestrator] Final synthesis ${this.elapsed()}`);
|
|
436
|
-
// Only spin up the evaluation-phase UI when
|
|
437
|
-
//
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
if (
|
|
443
|
-
|
|
444
|
-
|
|
505
|
+
// Only spin up the evaluation-phase UI when the loop ended on maxRounds. If the
|
|
506
|
+
// router already chose to finish inside the loop, its eval slice was completed at
|
|
507
|
+
// that point (onEvaluationDecision) and its decision is final — re-adding a fresh
|
|
508
|
+
// 'eval' box here would leave a perpetually-"evaluating" slice that nothing ever
|
|
509
|
+
// completes, and re-emit start/progress after the decision. Symmetric with the
|
|
510
|
+
// decision guard below.
|
|
511
|
+
if (!routerChoseSynthesis) {
|
|
512
|
+
// The synthesis pass announces the round it is synthesizing AT, which is the
|
|
513
|
+
// one the loop stopped on — not `maxRounds + 1`, a number outside the budget
|
|
514
|
+
// every other event is expressed in. That was harmless while the loop
|
|
515
|
+
// announced the phantom final round (1,2,3 then a 4 nobody minded); with the
|
|
516
|
+
// phantom gone it left a hole: a 3-round run emitted round_start 1, 2, 4.
|
|
517
|
+
// Guarded so a one-round budget cannot announce the same round twice.
|
|
518
|
+
if (this.currentRound > lastAnnouncedRound) {
|
|
519
|
+
observer?.onRoundStart?.(this.currentRound);
|
|
520
|
+
lastAnnouncedRound = this.currentRound;
|
|
521
|
+
}
|
|
522
|
+
// The round this synthesis actually happens at, not the budget. They coincide on
|
|
523
|
+
// the round-cap exit but not on the wait-exhaustion one, which breaks out early —
|
|
524
|
+
// that path announced `evaluation_start` for a round no `round_start` had ever
|
|
525
|
+
// named, and the round it really stopped on was left with an evaluation slice
|
|
526
|
+
// that never received a decision.
|
|
527
|
+
observer?.onEvaluationStart?.(this.currentRound);
|
|
445
528
|
observer?.onEvaluationProgress?.('evaluating');
|
|
446
529
|
}
|
|
447
530
|
|
|
@@ -451,81 +534,52 @@ export class DeepResearchOrchestrator {
|
|
|
451
534
|
// toast and then be destroyed at teardown. With the flag off first (this and
|
|
452
535
|
// the consume run synchronously — no interleaving window), any later steer
|
|
453
536
|
// takes the input handler's fall-through path (forwarded to pi, accurate
|
|
454
|
-
// toast). On the
|
|
537
|
+
// toast). On the router-chose-synthesis path this is idempotent: the
|
|
455
538
|
// observer already flipped the flag in onEvaluationDecision('synthesize').
|
|
456
539
|
// NOTE: this must also come after the onEvaluationStart above, which re-sets
|
|
457
540
|
// the flag to true (correct for in-loop evaluations, wrong for this final one).
|
|
458
541
|
observer?.onSynthesisStart?.();
|
|
459
542
|
|
|
460
543
|
// One final check for new steering messages before synthesis. Done for BOTH
|
|
461
|
-
// exit paths: a message arriving after the
|
|
462
|
-
//
|
|
463
|
-
//
|
|
544
|
+
// exit paths: a message arriving after the router chose to finish was previously
|
|
545
|
+
// never consumed nor appended — fully dropped. Consuming here marks it active so
|
|
546
|
+
// appendSteeringGuidance includes it.
|
|
464
547
|
consumeQueuedMessages(this.options.sessionId);
|
|
465
548
|
const finalSteeringTexts = getSteeringMessages(this.options.sessionId).map(m => m.text);
|
|
466
549
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
observer,
|
|
489
|
-
excludeTools: this.options.excludeTools,
|
|
490
|
-
steeringMessages: finalSteeringTexts,
|
|
491
|
-
});
|
|
492
|
-
}
|
|
550
|
+
const synthesisServiceFinal = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
|
|
551
|
+
const finalReport: ResearchPlan = await planningService.updatePlanForRound({
|
|
552
|
+
sessionId: researchId,
|
|
553
|
+
query: query,
|
|
554
|
+
complexity,
|
|
555
|
+
round: this.currentRound,
|
|
556
|
+
model: coordinatorModel,
|
|
557
|
+
modelRegistry: ctx.modelRegistry,
|
|
558
|
+
cwd: ctx.cwd,
|
|
559
|
+
config: this.config,
|
|
560
|
+
reports: synthesisServiceFinal.getAllReports(researchId),
|
|
561
|
+
previousPlan: planningService.getCurrentPlan(researchId),
|
|
562
|
+
totalResearchersPlanned: planningService.getTotalResearchersPlanned(researchId),
|
|
563
|
+
mustSynthesize: true,
|
|
564
|
+
// Live, steering-extended budget — see the in-loop call above.
|
|
565
|
+
maxRounds,
|
|
566
|
+
signal,
|
|
567
|
+
observer,
|
|
568
|
+
excludeTools: this.options.excludeTools,
|
|
569
|
+
steeringMessages: finalSteeringTexts,
|
|
570
|
+
});
|
|
493
571
|
|
|
494
572
|
// Only emit the synthesis decision here for the FORCED-synthesis path. When the
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
if (
|
|
499
|
-
|
|
573
|
+
// router chose to finish inside the loop, onEvaluationDecision('synthesize', ...)
|
|
574
|
+
// was already fired at that point; emitting again would double-count synthesis for
|
|
575
|
+
// observers (e.g. the TUI progress bar).
|
|
576
|
+
if (!routerChoseSynthesis) {
|
|
577
|
+
// Same round the matching onEvaluationStart above used — see there.
|
|
578
|
+
observer?.onEvaluationDecision?.('synthesize', finalReport, this.currentRound);
|
|
500
579
|
}
|
|
501
580
|
|
|
502
581
|
let result = finalReport.content || '';
|
|
503
582
|
|
|
504
|
-
// ZERO reports collected AND researchers actually failed — the run attempted
|
|
505
|
-
// research and produced nothing groundable. This gate is deliberately
|
|
506
|
-
// independent of the synthesis text: with the default single-researcher plan
|
|
507
|
-
// the fast-stop threshold (MAX_FAILED_RESEARCHERS = 2) never trips, so a lone
|
|
508
|
-
// researcher that exhausts its retries (e.g. a provider rate limit) reaches
|
|
509
|
-
// synthesis with no source material. Whether the LLM then returns an empty
|
|
510
|
-
// string OR chatty refusal prose ("no information was found"), that is a
|
|
511
|
-
// FAILED run: throw, and surface the recorded root causes so the user sees
|
|
512
|
-
// the 429/model error, not a shrug (and never a source-free report shipped
|
|
513
|
-
// as success). The failed-researcher condition keeps the designed
|
|
514
|
-
// immediate-synthesis path intact: a coordinator that chose to answer
|
|
515
|
-
// directly (no delegation, no failures) is not a failed run.
|
|
516
|
-
const synthesisCheckService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
|
|
517
|
-
const hasReports = synthesisCheckService.hasReports(researchId);
|
|
518
|
-
const failedIds = getFailedResearchers(this.options.sessionId, researchId);
|
|
519
|
-
if (!hasReports && failedIds.length > 0) {
|
|
520
|
-
const reasons = getResearcherFailureReasons(this.options.sessionId, researchId);
|
|
521
|
-
const causes = failedIds
|
|
522
|
-
.map((id) => `researcher ${id}: ${reasons[id] ?? 'no usable report'}`)
|
|
523
|
-
.join('; ');
|
|
524
|
-
throw new Error(
|
|
525
|
-
`Research produced no report — no source material was collected. Causes: ${causes}`
|
|
526
|
-
);
|
|
527
|
-
}
|
|
528
|
-
|
|
529
583
|
if (!result.trim()) {
|
|
530
584
|
if (hasReports) {
|
|
531
585
|
// Content is empty but reports exist — build a fallback synthesis.
|
|
@@ -169,7 +169,10 @@ export class QuickResearchOrchestrator {
|
|
|
169
169
|
.replace('{{store_section}}', () => storeSection)
|
|
170
170
|
.replace('{{evidence_section}}', () => evidenceLines.join('\n\n'))
|
|
171
171
|
.replace('{{coordination_section}}', '')
|
|
172
|
-
.replace('{{extra_tool_guidelines}}', '- `search`: Perform broad web searches (Round 1 only).')
|
|
172
|
+
.replace('{{extra_tool_guidelines}}', '- `search`: Perform broad web searches (Round 1 only).')
|
|
173
|
+
// Quick research has no research lead and no next round — see
|
|
174
|
+
// RESEARCHER_DIGEST_SECTION. Its report IS the deliverable.
|
|
175
|
+
.replace('{{digest_section}}', '');
|
|
173
176
|
|
|
174
177
|
logger.debug(`[QuickOrchestrator] System Prompt:\n${prompt}`);
|
|
175
178
|
|
|
@@ -325,7 +328,13 @@ export class QuickResearchOrchestrator {
|
|
|
325
328
|
// Store report in synthesis service so citations can be verified/processed
|
|
326
329
|
const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
|
|
327
330
|
synthesisService.storeReport(this.options.researchId, 'quick', result);
|
|
328
|
-
|
|
331
|
+
// Continue from the STORED body. Quick research uses the same researcher prompt as
|
|
332
|
+
// a deep run, so its report opens with a COVERAGE DIGEST block — routing metadata
|
|
333
|
+
// for a lead that does not exist in this mode. Unlike a deep run, this text IS the
|
|
334
|
+
// deliverable and goes straight to the user, so reading it back is what keeps the
|
|
335
|
+
// digest out of the delivered document.
|
|
336
|
+
result = synthesisService.getReport(this.options.researchId, 'quick') ?? result;
|
|
337
|
+
|
|
329
338
|
// Ensure CITED LINKS section is accurate and consistent
|
|
330
339
|
result = synthesisService.ensureCitedLinks(this.options.researchId, result);
|
|
331
340
|
|
|
@@ -14,7 +14,7 @@ import type { Model } from '@earendil-works/pi-ai';
|
|
|
14
14
|
import { resolveResearchModel } from '../core/llm/research-model-resolver.ts';
|
|
15
15
|
import type { QueryResultWithError } from '../web-research/types.ts';
|
|
16
16
|
import type { RunResearchersOptions } from './orchestration-types.ts';
|
|
17
|
-
import { RESEARCHER_LAUNCH_DELAY_MS, resolveExcludedTools } from '../constants.ts';
|
|
17
|
+
import { RESEARCHER_LAUNCH_DELAY_MS, RESEARCH_TOOL_NAMES, resolveExcludedTools } from '../constants.ts';
|
|
18
18
|
import { search } from '../web-research/search.ts';
|
|
19
19
|
import { parseCitations } from '../utils/text-utils.ts';
|
|
20
20
|
import { logger, resetLogger } from '../logger.ts';
|
|
@@ -98,6 +98,27 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
|
|
|
98
98
|
const configDisabledTools = researchConfig.DISABLED_TOOLS
|
|
99
99
|
? researchConfig.DISABLED_TOOLS.split(',').map((s) => s.trim()).filter(Boolean)
|
|
100
100
|
: [];
|
|
101
|
+
// Unknown names are a SILENT no-op downstream (the merge is a blind Set union),
|
|
102
|
+
// so a typo leaves the tool enabled for a whole billed run while the user
|
|
103
|
+
// believes it is off. `--exclude-tools` fails fast on this in the CLI parser;
|
|
104
|
+
// its two siblings — the SDK's `excludeTools` option and
|
|
105
|
+
// PI_RESEARCH_DISABLED_TOOLS — reach the same union with no check at all, and
|
|
106
|
+
// RESEARCH_TOOL_NAMES already documents itself as the vocabulary all THREE
|
|
107
|
+
// accept. Warn rather than throw here: this chokepoint is mid-run and serves a
|
|
108
|
+
// library API and an ambient env var, where aborting a legitimate request over
|
|
109
|
+
// one unrecognized name is the wrong trade — the CLI keeps its fail-fast.
|
|
110
|
+
for (const [surface, names] of [
|
|
111
|
+
['excludeTools', excludeTools ?? []],
|
|
112
|
+
['PI_RESEARCH_DISABLED_TOOLS', configDisabledTools],
|
|
113
|
+
] as const) {
|
|
114
|
+
const unknown = names.filter((t) => !RESEARCH_TOOL_NAMES.includes(t));
|
|
115
|
+
if (unknown.length > 0) {
|
|
116
|
+
logger.warn(
|
|
117
|
+
`[ResearchOrchestrationService] ${surface}: ignoring unknown tool name${unknown.length === 1 ? '' : 's'} ` +
|
|
118
|
+
`${unknown.map((t) => `"${t}"`).join(', ')} — these exclude nothing. Valid names: ${RESEARCH_TOOL_NAMES.join(', ')}.`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
101
122
|
const mergedExcludeTools = resolveExcludedTools(excludeTools, configDisabledTools);
|
|
102
123
|
|
|
103
124
|
// Resolve model using centralized priority logic
|
|
@@ -643,6 +664,14 @@ export class ResearchOrchestrationService implements IResearchOrchestration {
|
|
|
643
664
|
* @param ctx - Optional extension context for container isolation
|
|
644
665
|
*/
|
|
645
666
|
async checkHealth(round: number, _researchId?: string, ctx?: any): Promise<void> {
|
|
667
|
+
// Round 1 is skipped because there is nothing to diagnose yet — the pool was
|
|
668
|
+
// built moments earlier and no work has run through it. A consequence worth
|
|
669
|
+
// stating: a complexity-1 run has exactly ONE research round, so it now never
|
|
670
|
+
// runs this at all. That is correct rather than a gap. It used to run at the
|
|
671
|
+
// phantom final round, which dispatched no researchers and on one measured run
|
|
672
|
+
// spent 105 seconds probing infrastructure that the synthesis pass ahead of it
|
|
673
|
+
// does not use. This check is advisory logging; a quick run loses nothing but
|
|
674
|
+
// that cost.
|
|
646
675
|
if (round <= 1) return;
|
|
647
676
|
|
|
648
677
|
try {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { parseCitations } from '../utils/text-utils.ts';
|
|
13
13
|
import { normalizeCitations, formatCitedLinks, rewriteCitationMarkers, type GlobalCitation } from '../utils/citation-utils.ts';
|
|
14
|
+
import { splitCoverageDigest, deriveCoverageDigest } from '../utils/coverage-digest.ts';
|
|
14
15
|
import { lastCitedLinksHeaderIndex, lastCitedLinksHeader } from '../utils/text-utils.ts';
|
|
15
16
|
import { getScrapedLinks, isTranscribedLink, normalizeUrl } from '../utils/shared-links.ts';
|
|
16
17
|
import { stripTrailingLlmPunctuation } from '../utils/url-utils.ts';
|
|
@@ -77,6 +78,12 @@ export class ResearchSynthesisService implements IService {
|
|
|
77
78
|
|
|
78
79
|
// Map of sessionId -> Map<reportId, reportContent>
|
|
79
80
|
private sessions = new Map<string, Map<string, string>>();
|
|
81
|
+
// Map of sessionId -> Map<reportId, coverageDigest>, split off the report at store time.
|
|
82
|
+
// Kept parallel to `sessions` rather than folded into it so every existing consumer of a
|
|
83
|
+
// report keeps receiving a plain string body. Lifecycle is mirrored everywhere `sessions`
|
|
84
|
+
// is mutated (store, LRU evict, clear, dispose) — a digest map that outlives its reports
|
|
85
|
+
// would hand the router coverage for findings the synthesizer no longer has.
|
|
86
|
+
private digests = new Map<string, Map<string, string>>();
|
|
80
87
|
// Cap to prevent unbounded growth from orphaned sessions during long-lived Pi
|
|
81
88
|
// sessions. Generous because each session map is small and the normal path clears
|
|
82
89
|
// reports via cleanupResearchServices on run completion — this is only a backstop
|
|
@@ -103,6 +110,7 @@ export class ResearchSynthesisService implements IService {
|
|
|
103
110
|
if (oldestKey !== undefined) {
|
|
104
111
|
logger.warn(`[ResearchSynthesisService] Session limit (${ResearchSynthesisService.MAX_SESSIONS}) reached, evicting least-recently-used: ${oldestKey}`);
|
|
105
112
|
this.sessions.delete(oldestKey);
|
|
113
|
+
this.digests.delete(oldestKey);
|
|
106
114
|
}
|
|
107
115
|
}
|
|
108
116
|
reports = new Map<string, string>();
|
|
@@ -136,7 +144,47 @@ export class ResearchSynthesisService implements IService {
|
|
|
136
144
|
* @param report - The researcher's report content
|
|
137
145
|
*/
|
|
138
146
|
storeReport(sessionId: string, id: string, report: string): void {
|
|
139
|
-
|
|
147
|
+
// Split the coverage digest off the head of the report here, at the single point every
|
|
148
|
+
// report enters the system. Downstream consumers — citation normalization, the
|
|
149
|
+
// synthesis corpus, the fallback synthesis, the knowledge store — then see a body with
|
|
150
|
+
// no routing metadata in it, unchanged from before this protocol existed.
|
|
151
|
+
const { digest, body } = splitCoverageDigest(report);
|
|
152
|
+
this.getSessionReports(sessionId).set(id, body);
|
|
153
|
+
|
|
154
|
+
// Storing the same id twice REPLACES the report, so it must replace the digest too.
|
|
155
|
+
// Setting only when the new report carries one would leave the previous report's
|
|
156
|
+
// coverage claims attached to a body that no longer supports them — the router would
|
|
157
|
+
// then route on findings that were superseded. Delete on absence so the derived digest
|
|
158
|
+
// (which reads the current body) takes over instead.
|
|
159
|
+
let sessionDigests = this.digests.get(sessionId);
|
|
160
|
+
if (digest) {
|
|
161
|
+
if (!sessionDigests) {
|
|
162
|
+
sessionDigests = new Map<string, string>();
|
|
163
|
+
this.digests.set(sessionId, sessionDigests);
|
|
164
|
+
}
|
|
165
|
+
sessionDigests.set(id, digest);
|
|
166
|
+
} else if (sessionDigests) {
|
|
167
|
+
sessionDigests.delete(id);
|
|
168
|
+
if (sessionDigests.size === 0) this.digests.delete(sessionId);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Coverage digests for every stored report, in report order.
|
|
174
|
+
*
|
|
175
|
+
* A report whose researcher emitted no digest gets one derived from its body, so the
|
|
176
|
+
* router sees an entry for every researcher that ran. The alternative — omitting it —
|
|
177
|
+
* reads to the router as "that researcher produced nothing", which is both wrong and
|
|
178
|
+
* the more dangerous error: it argues for re-delegating work already done.
|
|
179
|
+
*/
|
|
180
|
+
getAllDigests(sessionId: string): Map<string, string> {
|
|
181
|
+
const reports = this.peekSessionReports(sessionId);
|
|
182
|
+
const stored = this.digests.get(sessionId);
|
|
183
|
+
const out = new Map<string, string>();
|
|
184
|
+
for (const [id, body] of reports.entries()) {
|
|
185
|
+
out.set(id, stored?.get(id) ?? deriveCoverageDigest(body));
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
140
188
|
}
|
|
141
189
|
|
|
142
190
|
/**
|
|
@@ -187,8 +235,10 @@ export class ResearchSynthesisService implements IService {
|
|
|
187
235
|
clearReports(sessionId?: string): void {
|
|
188
236
|
if (sessionId) {
|
|
189
237
|
this.sessions.delete(sessionId);
|
|
238
|
+
this.digests.delete(sessionId);
|
|
190
239
|
} else {
|
|
191
240
|
this.sessions.clear();
|
|
241
|
+
this.digests.clear();
|
|
192
242
|
}
|
|
193
243
|
}
|
|
194
244
|
|
|
@@ -442,6 +492,7 @@ export class ResearchSynthesisService implements IService {
|
|
|
442
492
|
*/
|
|
443
493
|
reset(): void {
|
|
444
494
|
this.sessions.clear();
|
|
495
|
+
this.digests.clear();
|
|
445
496
|
}
|
|
446
497
|
|
|
447
498
|
async initialize(): Promise<void> {
|
|
@@ -461,6 +512,7 @@ export class ResearchSynthesisService implements IService {
|
|
|
461
512
|
this.lifecycle = ServiceLifecycle.DISPOSING;
|
|
462
513
|
logger.debug('[ResearchSynthesisService] Disposing...');
|
|
463
514
|
this.sessions.clear();
|
|
515
|
+
this.digests.clear();
|
|
464
516
|
this.lifecycle = ServiceLifecycle.DISPOSED;
|
|
465
517
|
logger.debug('[ResearchSynthesisService] Disposed');
|
|
466
518
|
}
|
|
@@ -23,6 +23,42 @@ import { recordResearcherFailure, getSteeringMessages, shouldStopResearch } from
|
|
|
23
23
|
import { isAbortSentinel, boundSessionAbort } from './abort-utils.ts';
|
|
24
24
|
import type { RunResearcherOptions } from './orchestration-types.ts';
|
|
25
25
|
import { search } from '../web-research/search.ts';
|
|
26
|
+
import { RESEARCHER_DIGEST_SECTION } from '../utils/coverage-digest.ts';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Failures a retry cannot fix, because the next attempt sends the same request to
|
|
30
|
+
* the same account and gets the same answer.
|
|
31
|
+
*
|
|
32
|
+
* Deliberately narrow. The retry loop is otherwise unclassified and that is mostly
|
|
33
|
+
* FINE — the log history says so. Across 53 days the loop retried 126 attempts:
|
|
34
|
+
* "produced no text output" recovered 26/26; a provider rejecting
|
|
35
|
+
* `reasoning_effort: 'none'` recovered ~18/19, because OpenRouter routes the same
|
|
36
|
+
* model to different upstream providers per request and only some reject it;
|
|
37
|
+
* token-limit truncation recovered ~16/17. Classifying any of those as permanent
|
|
38
|
+
* would break runs that currently succeed.
|
|
39
|
+
*
|
|
40
|
+
* Insufficient credits is the one class with no recoveries at all: 30 retried
|
|
41
|
+
* attempts, every one belonging to an episode that went on to exhaust, 0 of 10
|
|
42
|
+
* recovered. Those ten cost five runs their entire output — searches already paid
|
|
43
|
+
* for, 70 results collected and discarded, and a synthesis call billed over an
|
|
44
|
+
* empty corpus before the run threw. The account balance does not change in the
|
|
45
|
+
* two seconds between attempts.
|
|
46
|
+
*
|
|
47
|
+
* Note what this does NOT cover: a researcher timeout retries and recovers ~88% of
|
|
48
|
+
* the time, so it stays retriable even though three attempts at a 15-minute budget
|
|
49
|
+
* cost 45 minutes of wall clock. That cost is real and worth addressing, but by
|
|
50
|
+
* bounding total retry time rather than by refusing the retry that usually works.
|
|
51
|
+
*/
|
|
52
|
+
export function isUnretriableResearcherError(error: unknown): boolean {
|
|
53
|
+
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
54
|
+
// Matched on the provider's wording rather than a bare "402": a status code can
|
|
55
|
+
// appear inside an unrelated payload (a token count, a URL, a quoted response
|
|
56
|
+
// body), and this gate ends a researcher's run — it must not fire on a
|
|
57
|
+
// coincidence. Same reasoning as isTransientSynthesisError's boundary anchoring.
|
|
58
|
+
return message.includes('requires more credits')
|
|
59
|
+
|| message.includes('insufficient credits')
|
|
60
|
+
|| message.includes('insufficient_quota');
|
|
61
|
+
}
|
|
26
62
|
|
|
27
63
|
/**
|
|
28
64
|
* Run a single researcher with retries
|
|
@@ -205,6 +241,7 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
|
|
|
205
241
|
.replace('{{evidence_section}}', () => evidenceSection)
|
|
206
242
|
.replace('{{coordination_section}}', () => previousQueriesSection)
|
|
207
243
|
.replace('{{extra_tool_guidelines}}', '')
|
|
244
|
+
.replace('{{digest_section}}', () => RESEARCHER_DIGEST_SECTION)
|
|
208
245
|
.trim() + steeringSection;
|
|
209
246
|
|
|
210
247
|
logger.debug(`[ResearcherExecutor] Researcher ${id} attempt ${attempt} System Prompt:\n${prompt}`);
|
|
@@ -453,7 +490,11 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
|
|
|
453
490
|
const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
|
|
454
491
|
synthesisService.storeReport(researchId, `${round}.${id}`, responseText);
|
|
455
492
|
|
|
456
|
-
|
|
493
|
+
// Emit what was STORED, not what the model returned: storeReport splits the coverage
|
|
494
|
+
// digest off the head of the report, and the SDK's `researcher_complete` event should
|
|
495
|
+
// carry the report a consumer would get from the run, not the routing metadata that
|
|
496
|
+
// was stripped out of it.
|
|
497
|
+
observer?.onResearcherComplete?.(id, synthesisService.getReport(researchId, `${round}.${id}`) ?? responseText);
|
|
457
498
|
return;
|
|
458
499
|
} catch (err) {
|
|
459
500
|
lastError = err;
|
|
@@ -484,7 +525,8 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
|
|
|
484
525
|
const synthesisService = await getService<IResearchSynthesisService>(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
|
|
485
526
|
synthesisService.storeReport(researchId, `${round}.${id}`, partialResponse + '\n\n---\n*WARNING: This report was truncated due to a timeout/error. Content may be incomplete.*');
|
|
486
527
|
logger.log(`[ResearcherExecutor] Researcher ${id} salvaged partial content (${partialResponse.length} chars) after error: ${errMsg}`);
|
|
487
|
-
|
|
528
|
+
// As on the success path: emit the stored body, with the digest already split off.
|
|
529
|
+
observer?.onResearcherComplete?.(id, synthesisService.getReport(researchId, `${round}.${id}`) ?? partialResponse);
|
|
488
530
|
return;
|
|
489
531
|
}
|
|
490
532
|
} catch (salvageErr) {
|
|
@@ -508,6 +550,15 @@ export async function runResearcher(options: RunResearcherOptions): Promise<void
|
|
|
508
550
|
break; // Break out of the attempt loop
|
|
509
551
|
}
|
|
510
552
|
|
|
553
|
+
// A failure the account cannot retry its way out of. Spending the remaining
|
|
554
|
+
// attempts changes nothing except how long the user waits for the same answer,
|
|
555
|
+
// and it buries the real cause under three duplicate warnings.
|
|
556
|
+
if (isUnretriableResearcherError(err)) {
|
|
557
|
+
logger.error(`[ResearcherExecutor] Researcher ${id} failed with an unretriable provider error, not retrying: ${errMsg}`);
|
|
558
|
+
metrics.increment('researcher_unretriable_total', 1, { mode: 'deep', complexity: String(complexity) });
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
561
|
+
|
|
511
562
|
if (attempt < maxAttempts) {
|
|
512
563
|
logger.warn(`[ResearcherExecutor] Researcher ${id} attempt ${attempt} failed: ${errMsg}; will retry`);
|
|
513
564
|
} else {
|
|
@@ -109,10 +109,23 @@ export async function createResearcherSession(options: CreateResearcherSessionOp
|
|
|
109
109
|
if (usage && typeof usage.tokens === 'number') return usage.tokens;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
// Fallback to cumulative session usage
|
|
112
|
+
// Fallback to cumulative session usage. Cache-read and cache-write tokens are
|
|
113
|
+
// part of the prompt and occupy the context window exactly like uncached input;
|
|
114
|
+
// the providers simply bill them differently. pi-ai reports them as SEPARATE
|
|
115
|
+
// fields with `input` already net of both (openai-completions computes
|
|
116
|
+
// `input = prompt_tokens - cacheRead - cacheWrite`), so summing input+output
|
|
117
|
+
// alone under-reports the real context by the entire cached prefix. On a
|
|
118
|
+
// provider where caching works that is most of the prompt, which would leave
|
|
119
|
+
// the tool layer's context gate (it divides this by contextWindowSize) reading
|
|
120
|
+
// near-empty on a session that is nearly full.
|
|
113
121
|
if (typeof (sessionRef.session as any).getUsage === 'function') {
|
|
114
122
|
const usage = (sessionRef.session as any).getUsage();
|
|
115
|
-
return (
|
|
123
|
+
return (
|
|
124
|
+
(usage.input || 0) +
|
|
125
|
+
(usage.output || 0) +
|
|
126
|
+
(usage.cacheRead || 0) +
|
|
127
|
+
(usage.cacheWrite || 0)
|
|
128
|
+
);
|
|
116
129
|
}
|
|
117
130
|
|
|
118
131
|
return 0;
|
|
@@ -61,7 +61,7 @@ The `research` tool retrieves from the internet. Do NOT use it when the answer c
|
|
|
61
61
|
- `depth: 3` — **ABSOLUTELY NEVER without explicit user request.** The user MUST use trigger words like "ultra", "exhaustive", "comprehensive deep-dive", or "maximum research". If the user does not explicitly request this level, use depth 1 or depth 2.
|
|
62
62
|
|
|
63
63
|
**How depth works internally:**
|
|
64
|
-
- `depth: 1-3` — AI-orchestrated: coordinator plans a team, researchers execute,
|
|
64
|
+
- `depth: 1-3` — AI-orchestrated: coordinator plans a team, researchers execute, a research lead decides whether to continue deeper and writes the final report. Team size and number of rounds scale with complexity. The coordinator and the lead dynamically determine how many researchers are needed each round — it's not a fixed number.
|
|
65
65
|
|
|
66
66
|
**Max siblings per round by depth:**
|
|
67
67
|
- `depth: 1` — up to {{max_team_size_l1}} researchers per round
|
|
@@ -73,6 +73,7 @@ Write your report immediately after scraping is complete, or as soon as a tool r
|
|
|
73
73
|
|
|
74
74
|
## Report Format
|
|
75
75
|
|
|
76
|
+
{{digest_section}}
|
|
76
77
|
Use [N] inline citations throughout. Write in plain prose — no markdown headings, no bullet points, no bold or italic text. Use clear paragraphs separated by blank lines. The full CITED LINKS list goes at the very end.
|
|
77
78
|
|
|
78
79
|
[Topic Title]
|
|
@@ -36,7 +36,7 @@ Before splitting the query into researchers, pin down its scope — exactly what
|
|
|
36
36
|
|
|
37
37
|
1. **Plan ONLY Round 1**: Split the query into distinct sub-topics, all inside the scope above, each assigned to a researcher. **Do NOT plan multiple rounds in advance** — additional rounds are only delegated reactively when Round 1 findings reveal gaps.
|
|
38
38
|
2. **Assign Goals**: Each researcher gets a focused goal covering a specific angle or time period.
|
|
39
|
-
3. **Temporal Awareness**: Lead with the current month and year shown above. Generate time-sensitive queries using that exact month and year (e.g., append the current year and, where relevant, the current month; "latest", "current status"). Never anchor queries to an older year
|
|
39
|
+
3. **Temporal Awareness**: Lead with the current month and year shown above. Generate time-sensitive queries using that exact month and year (e.g., append the current year and, where relevant, the current month; "latest", "current status"). Never anchor queries to an older year than the one shown above unless the user explicitly asks about the past. Research MUST be grounded in the present month and year.
|
|
40
40
|
4. **Query Planning**: For EACH researcher, generate targeted, specific queries within the budget. Fill the budget when the topic is broad enough to warrant it, but don't pad queries for a narrow topic. Each round is planned independently — make Round 1 count.
|
|
41
41
|
5. **YouTube Discovery**: For roughly **one in {{youtube_query_every_n}}** of each researcher's queries, append the word `youtube` to the query text (e.g. `"<topic> explained youtube"`). DuckDuckGo rarely surfaces YouTube on its own, and YouTube links let researchers read video transcripts. Skip this only when the topic is clearly unsuited to video (e.g. a single numeric lookup).
|
|
42
42
|
6. **Seed Search Burst**: All queries across all researchers are combined into a single pre-search pass that seeds the global link pool.
|