@lincoln504/pi-research 1.3.12 → 1.4.3
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 +5670 -4705
- package/dist/prompts/research-tool-usage.md +1 -1
- package/dist/prompts/researcher.md +1 -0
- package/dist/prompts/system-lead-router.md +122 -0
- package/dist/prompts/system-lead-synthesizer.md +46 -0
- package/dist/thread-worker.mjs +169 -62
- package/docs/ARCHITECTURE.md +18 -10
- package/docs/CHANGELOG.md +225 -0
- package/docs/CONFIGURATION.md +106 -7
- package/package.json +2 -2
- package/scripts/build.cjs +8 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +5 -1
- package/src/config.ts +22 -12
- 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 +766 -91
- package/src/core/planning-utils.ts +21 -5
- package/src/core/service-initialization.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 +236 -43
- package/src/infrastructure/browser/config.ts +79 -1
- 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 +5 -5
- package/src/infrastructure/browser/thread-worker-browser.ts +87 -36
- package/src/infrastructure/browser/thread-worker-messaging.ts +99 -10
- package/src/infrastructure/browser/thread-worker.mjs +169 -62
- package/src/infrastructure/browser/thread-worker.ts +13 -5
- 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/embedding/embedding-server.ts +34 -0
- package/src/infrastructure/file-lock-service.ts +214 -19
- 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 +46 -19
- package/src/knowledge/embedder.ts +11 -3
- package/src/knowledge/index.ts +36 -10
- package/src/knowledge/store.ts +29 -5
- package/src/logger.ts +11 -0
- package/src/orchestration/deep-research-orchestrator.ts +143 -87
- package/src/orchestration/quick-research-orchestrator.ts +11 -2
- package/src/orchestration/research-orchestration-service.ts +8 -0
- 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/orchestration/session-state.ts +16 -0
- package/src/prompts/research-tool-usage.md +1 -1
- package/src/prompts/researcher.md +1 -0
- package/src/prompts/system-lead-router.md +122 -0
- package/src/prompts/system-lead-synthesizer.md +46 -0
- package/src/sdk.ts +10 -0
- package/src/skill-install/skill-installer.ts +5 -1
- package/src/stackexchange/index.ts +3 -0
- package/src/stackexchange/rest-client.ts +7 -12
- package/src/tools/research-knowledge-search.ts +11 -3
- package/src/tools/research-tool-definition.ts +38 -7
- package/src/tools/scrape.ts +6 -0
- package/src/tools/search.ts +7 -0
- package/src/tools/security.ts +31 -10
- 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/host-config.ts +12 -1
- package/src/utils/llm-usage.ts +33 -6
- package/src/utils/log-utils.ts +10 -0
- package/src/utils/metrics-summary.ts +20 -4
- package/src/utils/metrics.ts +27 -1
- package/src/web-research/browser-search.ts +102 -22
- package/src/web-research/web-scraper.ts +27 -16
- package/src/youtube/potoken.ts +8 -1
- package/src/youtube/transcript-client.ts +56 -5
- package/dist/prompts/system-lead-evaluator.md +0 -118
- package/src/prompts/system-lead-evaluator.md +0 -118
|
@@ -21,12 +21,16 @@ import { injectCurrentDate } from './llm/inject-date.ts';
|
|
|
21
21
|
import { loadPrompt } from './llm/prompts.ts';
|
|
22
22
|
import { recordLlmUsage } from '../utils/llm-usage.ts';
|
|
23
23
|
import { normalizeCitations } from '../utils/citation-utils.ts';
|
|
24
|
+
import { lastCitedLinksHeader } from '../utils/text-utils.ts';
|
|
24
25
|
import { repairJsonWithLlm } from './llm/agentic-repair.ts';
|
|
25
26
|
import { buildSafeOptions, validateAndExtractText } from './llm/llm-utils.ts';
|
|
26
27
|
import { safeGetApiKeyAndHeaders } from './llm/model-registry-factory.ts';
|
|
27
28
|
import { withTimeout } from './llm/llm-timeout.ts';
|
|
28
29
|
import { retryWithBackoff, isTransientError } from '../web-research/retry-utils.ts';
|
|
29
|
-
import { getConfig } from '../config.ts';
|
|
30
|
+
import { getConfig, type Config } from '../config.ts';
|
|
31
|
+
import type { Model } from '@earendil-works/pi-ai';
|
|
32
|
+
import { DEFAULT_MODEL_CONTEXT_WINDOW } from '../constants.ts';
|
|
33
|
+
import { deriveCoverageDigest, formatDigestsForRouter } from '../utils/coverage-digest.ts';
|
|
30
34
|
import {
|
|
31
35
|
PlanningResponseSchemaAsTSchema,
|
|
32
36
|
EvaluationResponseSchemaAsTSchema,
|
|
@@ -54,6 +58,120 @@ const LLM_MAX_RETRIES = 2;
|
|
|
54
58
|
const LLM_RETRY_INITIAL_DELAY_MS = 1000;
|
|
55
59
|
const LLM_RETRY_MAX_DELAY_MS = 8000;
|
|
56
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Output ceiling for a ROUTING call.
|
|
63
|
+
*
|
|
64
|
+
* The router emits a decision, never a report — at worst a delegate plan of
|
|
65
|
+
* `maxTeamSize` researchers each carrying `queryBudget` queries, which is a couple of
|
|
66
|
+
* thousand tokens at Level 3. It used to share the synthesis budget because the same call
|
|
67
|
+
* could return the final report; under the split protocol it cannot, so a ceiling this
|
|
68
|
+
* far above the real ceiling is only there to leave room for a model that reasons before
|
|
69
|
+
* answering. Still clamped to the model by buildSafeOptions.
|
|
70
|
+
*/
|
|
71
|
+
const ROUTER_MAX_TOKENS = 8192;
|
|
72
|
+
|
|
73
|
+
/** Rough bytes-per-token used to budget the synthesis corpus. Deliberately conservative. */
|
|
74
|
+
const CHARS_PER_TOKEN = 4;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Tokens held back from the context window, on top of the configured output ceiling, to
|
|
78
|
+
* cover the system prompt and the slack between a chars/4 estimate and a real tokenizer on
|
|
79
|
+
* prose that is heavy with URLs and citation markers.
|
|
80
|
+
*
|
|
81
|
+
* This does NOT cover the global source list or the run context (previous-queries section):
|
|
82
|
+
* both ride along on every partial and merge pass verbatim, but neither has a fixed size —
|
|
83
|
+
* the source list grows with every citation the run collected (each with a free-text
|
|
84
|
+
* description) and the run context grows with the run's entire query history, uncapped. A
|
|
85
|
+
* fixed reserve here cannot bound either, so `synthesisCorpusBudgetChars` takes their actual
|
|
86
|
+
* character length as a separate argument and subtracts it directly, at the point where it
|
|
87
|
+
* is already known (see synthesizeCorpus).
|
|
88
|
+
*/
|
|
89
|
+
const SYNTHESIS_OVERHEAD_TOKENS = 8000;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Floor on the corpus budget. Below this, partitioning produces so many partial passes
|
|
93
|
+
* that the reduce costs more than the overflow it avoids — better to send one oversized
|
|
94
|
+
* request and let the provider's own error surface than to fan out indefinitely.
|
|
95
|
+
*/
|
|
96
|
+
const MIN_SYNTHESIS_CORPUS_CHARS = 40_000;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* How many reduce passes the synthesizer may run before it merges whatever it has.
|
|
100
|
+
* A run whose corpus needs more than this has grown far past anything the round budget
|
|
101
|
+
* can produce; the bound exists so a pathological input cannot spin.
|
|
102
|
+
*/
|
|
103
|
+
const MAX_SYNTHESIS_REDUCE_PASSES = 3;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Floor on a partial pass's output ceiling. A wide fan-out divides the corpus budget many
|
|
107
|
+
* ways; without a floor a 12-partition reduce would allot each partial too few tokens to
|
|
108
|
+
* hold its slice of the findings, and "exhaustive synthesis" would quietly become
|
|
109
|
+
* summarisation. When the floor binds, the partials may not fit in one pass — that is what
|
|
110
|
+
* the remaining passes are for.
|
|
111
|
+
*/
|
|
112
|
+
const MIN_PARTIAL_SYNTHESIS_TOKENS = 4096;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Characters of corpus a single synthesis request may carry for `model`.
|
|
116
|
+
*
|
|
117
|
+
* The final synthesis is the one call whose input grows without bound: it carries every
|
|
118
|
+
* report the run collected. Nothing upstream caps it — there is no per-report budget and
|
|
119
|
+
* no cap on report count — so on a deep, steering-extended run it can exceed the model's
|
|
120
|
+
* context window, and the failure is expensive and late: the provider rejects the request
|
|
121
|
+
* after the entire run has been paid for, and the orchestrator degrades to a mechanical
|
|
122
|
+
* concatenation of the raw reports. Budgeting here converts that cliff into a bounded
|
|
123
|
+
* two-pass reduce.
|
|
124
|
+
*
|
|
125
|
+
* `extraOverheadChars` is the actual character length of the OTHER unbounded things that
|
|
126
|
+
* ride along on every partial and merge pass — the global source list and the run context —
|
|
127
|
+
* measured by the caller before partitioning, since both are already built by then. Without
|
|
128
|
+
* this, a run with a large citation list or a long query history can overflow the window
|
|
129
|
+
* through an input the budget never counted, which is exactly the failure class this
|
|
130
|
+
* function exists to prevent for the report corpus itself.
|
|
131
|
+
*/
|
|
132
|
+
export function synthesisCorpusBudgetChars(
|
|
133
|
+
model: { contextWindow?: number },
|
|
134
|
+
outputTokens: number,
|
|
135
|
+
extraOverheadChars = 0,
|
|
136
|
+
): number {
|
|
137
|
+
const window = model.contextWindow ?? DEFAULT_MODEL_CONTEXT_WINDOW;
|
|
138
|
+
const usable = window - outputTokens - SYNTHESIS_OVERHEAD_TOKENS - extraOverheadChars / CHARS_PER_TOKEN;
|
|
139
|
+
return Math.max(MIN_SYNTHESIS_CORPUS_CHARS, usable * CHARS_PER_TOKEN);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Greedily group corpus entries into runs that each fit `budgetChars`, preserving order.
|
|
144
|
+
*
|
|
145
|
+
* Order is preserved because global citation ids are assigned by first appearance across
|
|
146
|
+
* the reports in this same order; keeping partitions contiguous keeps each partial pass
|
|
147
|
+
* looking at a coherent, mostly-self-consistent slice of the numbering rather than an
|
|
148
|
+
* arbitrary scatter of it.
|
|
149
|
+
*
|
|
150
|
+
* A single entry larger than the whole budget gets a partition to itself and is truncated
|
|
151
|
+
* by the caller — at that point there is no partitioning left to do.
|
|
152
|
+
*/
|
|
153
|
+
export function partitionCorpus<T>(
|
|
154
|
+
entries: Array<[string, T]>,
|
|
155
|
+
sizeOf: (entry: [string, T]) => number,
|
|
156
|
+
budgetChars: number,
|
|
157
|
+
): Array<Array<[string, T]>> {
|
|
158
|
+
const partitions: Array<Array<[string, T]>> = [];
|
|
159
|
+
let current: Array<[string, T]> = [];
|
|
160
|
+
let currentSize = 0;
|
|
161
|
+
for (const entry of entries) {
|
|
162
|
+
const size = sizeOf(entry);
|
|
163
|
+
if (current.length > 0 && currentSize + size > budgetChars) {
|
|
164
|
+
partitions.push(current);
|
|
165
|
+
current = [];
|
|
166
|
+
currentSize = 0;
|
|
167
|
+
}
|
|
168
|
+
current.push(entry);
|
|
169
|
+
currentSize += size;
|
|
170
|
+
}
|
|
171
|
+
if (current.length > 0) partitions.push(current);
|
|
172
|
+
return partitions;
|
|
173
|
+
}
|
|
174
|
+
|
|
57
175
|
/**
|
|
58
176
|
* Whether a failed coordinator/evaluator LLM call should be RETRIED. Retries only fast,
|
|
59
177
|
* transient transport failures: an undici mid-stream abort ("terminated"), a dropped socket,
|
|
@@ -214,8 +332,8 @@ export class PlanningService implements IPlanningService {
|
|
|
214
332
|
return _getComplexityGuidance(complexity, maxTeamSize, queryBudget);
|
|
215
333
|
}
|
|
216
334
|
|
|
217
|
-
getEvaluatorComplexityGuidance(complexity: 1 | 2 | 3): string {
|
|
218
|
-
return _getEvaluatorComplexityGuidance(complexity);
|
|
335
|
+
getEvaluatorComplexityGuidance(complexity: 1 | 2 | 3, researchRounds: number): string {
|
|
336
|
+
return _getEvaluatorComplexityGuidance(complexity, researchRounds);
|
|
219
337
|
}
|
|
220
338
|
|
|
221
339
|
getRoundPhaseGuidance(currentRound: number, maxRounds: number, complexity: 1 | 2 | 3, maxTeamSize: number): string {
|
|
@@ -314,7 +432,8 @@ export class PlanningService implements IPlanningService {
|
|
|
314
432
|
}, buildSafeOptions(model, {
|
|
315
433
|
apiKey: authResult.apiKey || '',
|
|
316
434
|
headers: authResult.headers,
|
|
317
|
-
signal
|
|
435
|
+
signal,
|
|
436
|
+
sessionId,
|
|
318
437
|
}, config.PLANNING_MAX_TOKENS, config.LLM_THINKING_LEVEL)),
|
|
319
438
|
llmTimeout, 'coordinator-generatePlan',
|
|
320
439
|
);
|
|
@@ -350,6 +469,7 @@ export class PlanningService implements IPlanningService {
|
|
|
350
469
|
context: `Research planning for: ${query}`,
|
|
351
470
|
serviceName: 'PlanningService',
|
|
352
471
|
signal,
|
|
472
|
+
sessionId,
|
|
353
473
|
maxTokens: config.PLANNING_MAX_TOKENS,
|
|
354
474
|
thinkingLevel: config.LLM_THINKING_LEVEL,
|
|
355
475
|
onUsage: (rawUsage) => recordLlmUsage(model, rawUsage, { component: 'coordinator', complexity, observer }),
|
|
@@ -381,9 +501,15 @@ export class PlanningService implements IPlanningService {
|
|
|
381
501
|
logger.warn('[PlanningService] Coordinator produced no runnable researchers (empty queries); using single-researcher fallback');
|
|
382
502
|
plan = this.capResearcherQueries(this.buildFallbackCoordinatorPlan(responseText, query), complexity, this.name);
|
|
383
503
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
504
|
+
// Round 1 always delegates. `action` is optional in the parsed schema and the
|
|
505
|
+
// coordinator prompt neither documents nor asks for one, so any value here is
|
|
506
|
+
// the model volunteering — and the only value that changes anything is
|
|
507
|
+
// "synthesize", which asks to ship an answer written before a single source was
|
|
508
|
+
// read. With researchers present it slipped past the emptiness guard above, ended
|
|
509
|
+
// the loop at round 1, and became the final report: no queries, no scrapes, no
|
|
510
|
+
// citations, from a tool whose entire contract is grounded research. Nothing
|
|
511
|
+
// downstream wants a coordinator synthesis, so it is not honoured here.
|
|
512
|
+
plan.action = 'delegate';
|
|
387
513
|
this.currentPlans.set(sessionId, plan);
|
|
388
514
|
|
|
389
515
|
return plan;
|
|
@@ -422,16 +548,37 @@ export class PlanningService implements IPlanningService {
|
|
|
422
548
|
}
|
|
423
549
|
|
|
424
550
|
/**
|
|
425
|
-
* Update plan / evaluate progress after a round
|
|
551
|
+
* Update plan / evaluate progress after a round.
|
|
552
|
+
*
|
|
553
|
+
* This is TWO jobs behind one entry point, selected by `mustSynthesize`:
|
|
554
|
+
*
|
|
555
|
+
* - **Routing** (`mustSynthesize` falsy) — decide whether the run continues. Reads the
|
|
556
|
+
* researchers' COVERAGE DIGESTS, not their findings. It still grows with the number
|
|
557
|
+
* of researchers, but by one digest each rather than one report each — measured at
|
|
558
|
+
* two orders of magnitude less per researcher. It cannot produce the report.
|
|
559
|
+
* - **Synthesis** (`mustSynthesize` true) — write the report. Reads every report in
|
|
560
|
+
* full, exactly once, at the end of the run, under a corpus budget.
|
|
561
|
+
*
|
|
562
|
+
* They were one call reading the full corpus every round. That conflation is what made
|
|
563
|
+
* evaluator input grow with the square of the round count (round n re-sent rounds
|
|
564
|
+
* 1..n), put the final call at risk of exceeding the model's context window, and made
|
|
565
|
+
* the request shape uncacheable — a fresh single user message rebuilt each round, whose
|
|
566
|
+
* cache breakpoint therefore never matched. Splitting the roles removes the repetition
|
|
567
|
+
* rather than discounting it, so it needs no caching and behaves the same on every
|
|
568
|
+
* provider.
|
|
426
569
|
*/
|
|
427
570
|
async updatePlanForRound(options: UpdatePlanOptions): Promise<ResearchPlan> {
|
|
428
571
|
const { sessionId, query, complexity, round, model, reports, mustSynthesize, signal, observer, steeringMessages, modelRegistry } = options;
|
|
429
|
-
|
|
430
|
-
|
|
572
|
+
|
|
573
|
+
// The router runs on every round except the terminal one; synthesis runs once.
|
|
574
|
+
const isRouter = !mustSynthesize;
|
|
575
|
+
const role = isRouter ? 'router' : 'synthesizer';
|
|
576
|
+
|
|
577
|
+
logger.log(`[PlanningService] ${isRouter ? 'Routing after' : 'Synthesizing at'} Round ${round} for: "${query}"`);
|
|
431
578
|
|
|
432
579
|
const config = options.config ?? getConfig(options.cwd);
|
|
433
|
-
const promptTemplate = loadPrompt('system-lead-
|
|
434
|
-
|
|
580
|
+
const promptTemplate = loadPrompt(isRouter ? 'system-lead-router' : 'system-lead-synthesizer');
|
|
581
|
+
|
|
435
582
|
const maxTeamSize = this.getTeamSize(complexity);
|
|
436
583
|
const queryBudget = this.getQueryBudget(complexity);
|
|
437
584
|
// Prefer the caller's live, steering-extended round budget (e.g. the
|
|
@@ -440,8 +587,32 @@ export class PlanningService implements IPlanningService {
|
|
|
440
587
|
// the base per-complexity value when the caller doesn't supply one, so
|
|
441
588
|
// callers that don't pass it (other/test callers) keep prior behavior.
|
|
442
589
|
const maxRounds = options.maxRounds ?? _getMaxRounds(complexity);
|
|
443
|
-
|
|
444
|
-
|
|
590
|
+
// The round budget counts ITERATIONS, and the last one only ever synthesizes — the
|
|
591
|
+
// orchestrator leaves the loop before dispatching anyone. Phase guidance is about
|
|
592
|
+
// how much RESEARCH is left, so it must be computed against the rounds that can
|
|
593
|
+
// still research, not against the iteration count.
|
|
594
|
+
//
|
|
595
|
+
// Passing the raw budget told the router it had a round in hand that did not exist.
|
|
596
|
+
// At round 2 of 3 that is the difference between MIDDLE ("prefer delegation... with
|
|
597
|
+
// 1 round remaining") and LATE ("synthesize if the core question is answerable").
|
|
598
|
+
//
|
|
599
|
+
// To be precise about what was and was not wrong, because an earlier revision of
|
|
600
|
+
// this comment overstated it: a delegation made at round 2 DOES run — its
|
|
601
|
+
// researchers execute and their findings reach the synthesizer. What did not exist
|
|
602
|
+
// was the round AFTER it. The router was being calibrated as though it had another
|
|
603
|
+
// routing decision coming, when the one it was making was its last.
|
|
604
|
+
//
|
|
605
|
+
// Consequence worth naming: with the complexity table's 2/3/3 budgets, an unsteered
|
|
606
|
+
// run reaches the router exactly once, at the final research round, so LATE is the
|
|
607
|
+
// only phase it ever sees. That is correct — it IS the last decision — but it means
|
|
608
|
+
// the EARLY/MIDDLE branches are reachable only once steering has extended the
|
|
609
|
+
// budget. Anyone re-tuning this ladder should know the unsteered path no longer
|
|
610
|
+
// exercises it.
|
|
611
|
+
const researchRounds = Math.max(1, maxRounds - 1);
|
|
612
|
+
// Both of these are phrased against researchRounds, and must be: they land in the
|
|
613
|
+
// same prompt, and a router shown two different round budgets believes the larger.
|
|
614
|
+
const complexityGuidance = this.getEvaluatorComplexityGuidance(complexity, researchRounds);
|
|
615
|
+
const roundPhaseGuidance = this.getRoundPhaseGuidance(round, researchRounds, complexity, maxTeamSize);
|
|
445
616
|
|
|
446
617
|
// Inject steering if present
|
|
447
618
|
let steeringSection = '';
|
|
@@ -464,44 +635,69 @@ export class PlanningService implements IPlanningService {
|
|
|
464
635
|
? `\n## Previously Executed Queries\n${previousQueries.map(q => `- ${q}`).join('\n')}\n`
|
|
465
636
|
: '';
|
|
466
637
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
638
|
+
// Both lead prompts are deliberately ROUND-INVARIANT: they interpolate only values
|
|
639
|
+
// that are fixed for the whole run (complexity, team size, query budget, the
|
|
640
|
+
// disabled-tool list). Everything that changes between rounds — root query, round
|
|
641
|
+
// number, agenda, executed queries, steering, round-phase guidance — is appended to
|
|
642
|
+
// the END of the user message instead (see runContext below).
|
|
643
|
+
//
|
|
644
|
+
// The reason is prompt caching. Every provider caches on an exact prefix of the
|
|
645
|
+
// serialized request, and the system prompt is the front of that prefix: a single
|
|
646
|
+
// round-varying byte in it invalidates the entire request. Anthropic-style caching
|
|
647
|
+
// makes this sharper still — pi-ai places one cache_control breakpoint at the end of
|
|
648
|
+
// the system prompt (on `anthropic-messages`, and on `openai-completions` whenever
|
|
649
|
+
// the provider's `compat.cacheControlFormat` is "anthropic"), so a system prompt that
|
|
650
|
+
// changes each round means that breakpoint is written and never read, and because
|
|
651
|
+
// invalidation is hierarchical (tools -> system -> messages) the message-level
|
|
652
|
+
// breakpoint cannot hit either.
|
|
653
|
+
//
|
|
654
|
+
// Keeping it invariant means round 2+ re-reads one cache entry that is also shared by
|
|
655
|
+
// every other run at the same complexity. Measured with llm_cache_read_tokens_total
|
|
656
|
+
// (see utils/llm-usage.ts). Note this is a real but SMALL win: what dominated the
|
|
657
|
+
// evaluator's input was the findings corpus, and that is addressed structurally by
|
|
658
|
+
// the router/synthesizer split rather than by caching.
|
|
659
|
+
const systemPrompt = injectCurrentDate(promptTemplate, role);
|
|
660
|
+
// `partial_synthesis_section` is the only run-varying value either template takes, and
|
|
661
|
+
// only the synthesizer uses it — it names which reduce pass this call is. A
|
|
662
|
+
// single-pass synthesis and every router call pass '', so their system prompts stay
|
|
663
|
+
// byte-identical across the whole run.
|
|
664
|
+
const populate = (partialSection: string): string => this.populatePrompt(systemPrompt, {
|
|
472
665
|
complexity_label: complexity === 1 ? 'Level 1 (Normal)' : complexity === 2 ? 'Level 2 (Deep)' : 'Level 3 (Ultra)',
|
|
473
|
-
initial_agenda_section: initialAgendaSection,
|
|
474
|
-
previous_queries_section: previousQueriesSection,
|
|
475
|
-
additional_considerations: steeringSection,
|
|
476
666
|
disabled_tools_section: disabledToolsSection,
|
|
477
667
|
complexity_guidance: complexityGuidance,
|
|
478
|
-
round_phase_guidance: roundPhaseGuidance,
|
|
479
668
|
max_team_size: maxTeamSize,
|
|
480
669
|
query_budget: queryBudget,
|
|
481
670
|
youtube_query_every_n: config.YOUTUBE_QUERY_EVERY_N,
|
|
671
|
+
partial_synthesis_section: partialSection,
|
|
482
672
|
});
|
|
483
673
|
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
const
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
674
|
+
// Run-varying context, appended AFTER the bulk of the message (digests for the
|
|
675
|
+
// router, findings for the synthesizer). `reports` is cumulative and iterated in
|
|
676
|
+
// insertion order, and normalizeCitations assigns global ids by first appearance in
|
|
677
|
+
// that same order, so a report written in round 1 is byte-identical in later rounds'
|
|
678
|
+
// messages. Anything placed before it would destroy that: the global source list used
|
|
679
|
+
// to sit at the front and grew by a line per new URL, which moved every byte after it.
|
|
680
|
+
const runContext = [
|
|
681
|
+
`## RUN CONTEXT`,
|
|
682
|
+
`- **ROOT QUERY**: ${query}`,
|
|
683
|
+
// Router only, and in RESEARCH rounds — the same denominator as the phase
|
|
684
|
+
// guidance below, because two different round counts in one prompt is worse than
|
|
685
|
+
// either alone. The router's only decision is delegate-vs-synthesize, and the
|
|
686
|
+
// trailing synthesis pass is not a round it can spend.
|
|
687
|
+
//
|
|
688
|
+
// Omitted for the synthesizer, which has no round decision to calibrate. Showing
|
|
689
|
+
// it there reintroduced the impossible round number this denominator was meant to
|
|
690
|
+
// remove: the forced final synthesis runs AT the cap, so it read "Current round:
|
|
691
|
+
// 2 / 1" on every complexity-1 run — and then promised a final synthesis pass to
|
|
692
|
+
// the call that was itself the final synthesis pass.
|
|
693
|
+
isRouter ? `- **Current round**: ${round} / ${researchRounds} (research rounds; a final synthesis pass follows)` : '',
|
|
694
|
+
initialAgendaSection.trim(),
|
|
695
|
+
previousQueriesSection.trim(),
|
|
696
|
+
// Round-phase guidance calibrates a ROUTING decision against the round budget. The
|
|
697
|
+
// terminal synthesis has no decision left to make, so it is omitted there.
|
|
698
|
+
isRouter ? roundPhaseGuidance.trim() : '',
|
|
699
|
+
steeringSection.trim(),
|
|
700
|
+
].filter(Boolean).join('\n\n');
|
|
505
701
|
|
|
506
702
|
try {
|
|
507
703
|
const authResult = await safeGetApiKeyAndHeaders(modelRegistry, model);
|
|
@@ -509,52 +705,58 @@ export class PlanningService implements IPlanningService {
|
|
|
509
705
|
throw new Error(`Failed to get API key for evaluation: ${authResult.error}`);
|
|
510
706
|
}
|
|
511
707
|
|
|
512
|
-
const
|
|
708
|
+
const callOptions = {
|
|
709
|
+
model,
|
|
710
|
+
apiKey: authResult.apiKey || '',
|
|
711
|
+
headers: authResult.headers,
|
|
712
|
+
signal,
|
|
713
|
+
sessionId,
|
|
714
|
+
config,
|
|
715
|
+
complexity,
|
|
716
|
+
observer,
|
|
717
|
+
};
|
|
513
718
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
// Track usage once, on the successful attempt (failed attempts throw before here).
|
|
550
|
-
recordLlmUsage(model, (response as any).usage, { component: 'evaluator', complexity, observer });
|
|
719
|
+
const responseText = isRouter
|
|
720
|
+
? await (() => {
|
|
721
|
+
const routerMessage = buildRouterMessage(...this.buildRouterEvidence(options, round), runContext);
|
|
722
|
+
// The router carries no reduce mechanism the way synthesizeCorpus does — a
|
|
723
|
+
// routing decision made from PARTITIONED evidence isn't naturally mergeable
|
|
724
|
+
// the way synthesis prose is, and this call's output is a structured JSON
|
|
725
|
+
// delegate plan, not free text. Its input is bounded by team size in the
|
|
726
|
+
// common case (digests for earlier rounds are already capped — see
|
|
727
|
+
// MAX_DIGEST_CHARS), but nothing caps an individual researcher's fresh report
|
|
728
|
+
// body, so a round with a few unusually long reports can still overflow the
|
|
729
|
+
// window with no reduce path to fall back on. Warn rather than fail silently;
|
|
730
|
+
// the provider's own rejection is still the actual backstop, same as the
|
|
731
|
+
// synthesizer's over-budget-after-every-pass case above.
|
|
732
|
+
const window = model.contextWindow ?? DEFAULT_MODEL_CONTEXT_WINDOW;
|
|
733
|
+
const estimatedTokens = routerMessage.length / CHARS_PER_TOKEN + ROUTER_MAX_TOKENS + SYNTHESIS_OVERHEAD_TOKENS;
|
|
734
|
+
if (estimatedTokens > window) {
|
|
735
|
+
logger.warn(
|
|
736
|
+
`[PlanningService] Router request for ${model.id} is an estimated ` +
|
|
737
|
+
`${Math.round(estimatedTokens)} tokens against a ${window}-token context window ` +
|
|
738
|
+
`(evidence ${routerMessage.length} chars, output ceiling ${ROUTER_MAX_TOKENS} tokens). ` +
|
|
739
|
+
`Unlike the synthesizer, the router has no reduce path for an over-budget round — ` +
|
|
740
|
+
`sending it anyway, the provider may reject this request.`,
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
return this.callLead({
|
|
744
|
+
...callOptions,
|
|
745
|
+
systemPrompt: populate(''),
|
|
746
|
+
userMessage: routerMessage,
|
|
747
|
+
maxTokens: ROUTER_MAX_TOKENS,
|
|
748
|
+
component: 'router',
|
|
749
|
+
label: 'router-updatePlanForRound',
|
|
750
|
+
});
|
|
751
|
+
})()
|
|
752
|
+
: await this.synthesizeCorpus({ ...callOptions, reports, runContext, populate });
|
|
551
753
|
|
|
552
754
|
// Extract and validate JSON
|
|
553
755
|
let plan: ResearchPlan | null = null;
|
|
554
756
|
try {
|
|
555
757
|
plan = this.parseJsonPlan(responseText);
|
|
556
758
|
} catch {
|
|
557
|
-
logger.warn(
|
|
759
|
+
logger.warn(`[PlanningService] ${role} JSON malformed, attempting agentic repair`);
|
|
558
760
|
const repaired = await repairJsonWithLlm<ResearchPlan>(
|
|
559
761
|
responseText,
|
|
560
762
|
completeSimple,
|
|
@@ -562,12 +764,13 @@ export class PlanningService implements IPlanningService {
|
|
|
562
764
|
{
|
|
563
765
|
model,
|
|
564
766
|
schema: EvaluationResponseSchemaAsTSchema,
|
|
565
|
-
context: `Research
|
|
767
|
+
context: `Research ${role} for: ${query} (Round ${round})`,
|
|
566
768
|
serviceName: 'PlanningService',
|
|
567
769
|
signal,
|
|
568
|
-
|
|
770
|
+
sessionId,
|
|
771
|
+
maxTokens: isRouter ? ROUTER_MAX_TOKENS : config.SYNTHESIS_MAX_TOKENS,
|
|
569
772
|
thinkingLevel: config.LLM_THINKING_LEVEL,
|
|
570
|
-
onUsage: (rawUsage) => recordLlmUsage(model, rawUsage, { component:
|
|
773
|
+
onUsage: (rawUsage) => recordLlmUsage(model, rawUsage, { component: role, complexity, observer }),
|
|
571
774
|
}
|
|
572
775
|
);
|
|
573
776
|
if (repaired) {
|
|
@@ -588,16 +791,20 @@ export class PlanningService implements IPlanningService {
|
|
|
588
791
|
// if there is a prior agenda to continue, delegate another round rather than giving up.
|
|
589
792
|
// Round budget (maxRounds) still bounds this, so it cannot loop indefinitely.
|
|
590
793
|
if (mustSynthesize) {
|
|
591
|
-
logger.warn('[PlanningService] Failed to generate valid
|
|
794
|
+
logger.warn('[PlanningService] Failed to generate valid synthesis JSON on the final round; synthesizing from raw text');
|
|
592
795
|
const safeContent = salvageReportText(responseText);
|
|
593
796
|
plan = { action: 'synthesize', content: safeContent, researchers: [] };
|
|
594
797
|
} else if (previousPlan?.researchers && previousPlan.researchers.length > 0) {
|
|
595
|
-
logger.warn('[PlanningService]
|
|
798
|
+
logger.warn('[PlanningService] Routing decision unparseable mid-research; continuing the prior agenda rather than finishing early');
|
|
596
799
|
plan = { action: 'delegate', content: '', researchers: previousPlan.researchers };
|
|
597
800
|
} else {
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
801
|
+
// A ROUTER's raw text is never a report — it has not read the findings. Salvaging
|
|
802
|
+
// it as `content` (which is what this branch used to do, back when one call did
|
|
803
|
+
// both jobs) would ship the model's routing prose to the user as the run's
|
|
804
|
+
// output. Hand back an empty synthesis decision instead, so the orchestrator
|
|
805
|
+
// runs the real terminal synthesis over the collected reports.
|
|
806
|
+
logger.warn('[PlanningService] Routing decision unparseable and no prior agenda to continue; deferring to final synthesis');
|
|
807
|
+
plan = { action: 'synthesize', content: '', researchers: [] };
|
|
601
808
|
}
|
|
602
809
|
}
|
|
603
810
|
|
|
@@ -606,6 +813,29 @@ export class PlanningService implements IPlanningService {
|
|
|
606
813
|
// Respect mustSynthesize flag
|
|
607
814
|
if (mustSynthesize) {
|
|
608
815
|
finalPlan.action = 'synthesize';
|
|
816
|
+
} else if (finalPlan.action === 'synthesize' && finalPlan.content) {
|
|
817
|
+
// The router is told not to write prose and is given no findings to write it from,
|
|
818
|
+
// but a model that ignores that would produce an ungrounded report — and the
|
|
819
|
+
// orchestrator would ship it verbatim. Drop it; the terminal synthesis, which does
|
|
820
|
+
// read every report, writes the output.
|
|
821
|
+
logger.warn('[PlanningService] Router returned report content despite having no findings; discarding it in favour of the final synthesis');
|
|
822
|
+
finalPlan.content = '';
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// `action` is OPTIONAL in the schema, and parseJsonPlan accepts a plan without one
|
|
826
|
+
// as long as it carries researchers — its own comment says such a plan "defaults to
|
|
827
|
+
// delegation downstream", which was true of generatePlan and never of this method.
|
|
828
|
+
// A router emitting `{"researchers":[...]}` therefore fell through every branch
|
|
829
|
+
// below and was returned verbatim: no team-size cap, no per-researcher query
|
|
830
|
+
// budget, no round hard cap, and no duplicate-id renumbering — and duplicate ids
|
|
831
|
+
// collide on the `${round}.${id}` report key, silently discarding one researcher's
|
|
832
|
+
// entire report. The orchestrator then matched none of its `action === 'delegate'`
|
|
833
|
+
// branches, so it announced no decision and never grew the progress bar, yet ran
|
|
834
|
+
// the researchers anyway and the bar visibly regressed.
|
|
835
|
+
//
|
|
836
|
+
// 'wait' is preserved: the orchestrator acts on it, and it is not a delegation.
|
|
837
|
+
if (finalPlan.action !== 'synthesize' && finalPlan.action !== 'wait') {
|
|
838
|
+
finalPlan.action = 'delegate';
|
|
609
839
|
}
|
|
610
840
|
|
|
611
841
|
// Final safety cap if delegating
|
|
@@ -682,6 +912,266 @@ export class PlanningService implements IPlanningService {
|
|
|
682
912
|
}
|
|
683
913
|
}
|
|
684
914
|
|
|
915
|
+
/**
|
|
916
|
+
* Split what the router sees into evidence it must judge and history it already judged.
|
|
917
|
+
*
|
|
918
|
+
* The fidelity/cost trade this protocol turns on. Sending every report every round gives
|
|
919
|
+
* a correct decision but grows with the square of the round count; sending only digests
|
|
920
|
+
* is cheap but makes the decision hostage to what each researcher claims about its own
|
|
921
|
+
* work. Sending each report in full exactly ONCE — on the round it arrives — costs the
|
|
922
|
+
* corpus linearly and still puts real prose in front of every decision.
|
|
923
|
+
*/
|
|
924
|
+
private buildRouterEvidence(
|
|
925
|
+
options: UpdatePlanOptions,
|
|
926
|
+
round: number,
|
|
927
|
+
): [Map<string, { body: string; digest: string }>, Map<string, string>] {
|
|
928
|
+
const digests = this.resolveDigests(options);
|
|
929
|
+
const { freshIds, priorIds } = splitReportsByRound(options.reports, round);
|
|
930
|
+
|
|
931
|
+
const fresh = new Map<string, { body: string; digest: string }>();
|
|
932
|
+
for (const id of freshIds) {
|
|
933
|
+
fresh.set(id, {
|
|
934
|
+
body: stripCitedLinks(options.reports.get(id) ?? ''),
|
|
935
|
+
digest: digests.get(id) ?? '(none provided)',
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const prior = new Map<string, string>();
|
|
940
|
+
for (const id of priorIds) {
|
|
941
|
+
const digest = digests.get(id);
|
|
942
|
+
if (digest) prior.set(id, digest);
|
|
943
|
+
}
|
|
944
|
+
// A digest whose report is gone still belongs in the history block — dropping it hides
|
|
945
|
+
// that researcher from the router entirely.
|
|
946
|
+
for (const [id, digest] of digests.entries()) {
|
|
947
|
+
if (!fresh.has(id) && !prior.has(id)) prior.set(id, digest);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
return [fresh, prior];
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* Coverage digests for the router, one per researcher that has reported.
|
|
955
|
+
*
|
|
956
|
+
* Callers that hold the synthesis service pass them in. Callers that don't (tests, the
|
|
957
|
+
* SDK's direct entry points) get digests derived from the report bodies, so the router
|
|
958
|
+
* always sees an entry for every researcher rather than being handed nothing and
|
|
959
|
+
* concluding the round produced no work.
|
|
960
|
+
*/
|
|
961
|
+
private resolveDigests(options: UpdatePlanOptions): Map<string, string> {
|
|
962
|
+
// Fill per REPORT, not per supplied digest. Taking `options.digests` wholesale would
|
|
963
|
+
// leave the router blind to any researcher missing from it — and a researcher the
|
|
964
|
+
// router cannot see reads as one that produced nothing, which argues for re-delegating
|
|
965
|
+
// work already done. Iterating the reports guarantees exactly one entry per researcher
|
|
966
|
+
// whatever the caller passed.
|
|
967
|
+
const supplied = options.digests;
|
|
968
|
+
const resolved = new Map<string, string>();
|
|
969
|
+
for (const [id, report] of options.reports.entries()) {
|
|
970
|
+
resolved.set(id, supplied?.get(id) ?? deriveCoverageDigest(report));
|
|
971
|
+
}
|
|
972
|
+
// A digest for an id with no report (a caller tracking them separately) is still worth
|
|
973
|
+
// showing — dropping it would hide a researcher entirely.
|
|
974
|
+
if (supplied) {
|
|
975
|
+
for (const [id, digest] of supplied.entries()) {
|
|
976
|
+
if (!resolved.has(id)) resolved.set(id, digest);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
return resolved;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* One lead LLM call: transport retry, timeout, text validation, usage accounting.
|
|
984
|
+
*
|
|
985
|
+
* Shared by the router and by every pass of the synthesizer so all of them get the same
|
|
986
|
+
* transient-failure handling. Retries the transport call AND validation together (see
|
|
987
|
+
* generatePlan — a "terminated" abort only throws at validateAndExtractText, so
|
|
988
|
+
* validation must sit inside the retried region); repair and fallback stay outside, in
|
|
989
|
+
* the caller.
|
|
990
|
+
*/
|
|
991
|
+
private async callLead(args: {
|
|
992
|
+
model: Model<any>;
|
|
993
|
+
apiKey: string;
|
|
994
|
+
headers?: Record<string, string | null>;
|
|
995
|
+
signal?: AbortSignal;
|
|
996
|
+
sessionId: string;
|
|
997
|
+
config: Config;
|
|
998
|
+
complexity: 1 | 2 | 3;
|
|
999
|
+
observer?: UpdatePlanOptions['observer'];
|
|
1000
|
+
systemPrompt: string;
|
|
1001
|
+
userMessage: string;
|
|
1002
|
+
maxTokens: number;
|
|
1003
|
+
component: string;
|
|
1004
|
+
label: string;
|
|
1005
|
+
}): Promise<string> {
|
|
1006
|
+
const { model, signal, config, complexity, observer, label } = args;
|
|
1007
|
+
|
|
1008
|
+
const { response, responseText } = await retryWithBackoff(
|
|
1009
|
+
async () => {
|
|
1010
|
+
const response = await withTimeout(
|
|
1011
|
+
completeSimple(model, {
|
|
1012
|
+
systemPrompt: args.systemPrompt,
|
|
1013
|
+
messages: [
|
|
1014
|
+
{ role: 'user', content: [{ type: 'text', text: args.userMessage }], timestamp: Date.now() },
|
|
1015
|
+
],
|
|
1016
|
+
}, buildSafeOptions(model, {
|
|
1017
|
+
apiKey: args.apiKey,
|
|
1018
|
+
headers: args.headers,
|
|
1019
|
+
signal,
|
|
1020
|
+
sessionId: args.sessionId,
|
|
1021
|
+
}, args.maxTokens, config.LLM_THINKING_LEVEL)),
|
|
1022
|
+
config.LLM_TIMEOUT_MS, label,
|
|
1023
|
+
);
|
|
1024
|
+
const responseText = validateAndExtractText(response, label);
|
|
1025
|
+
return { response, responseText };
|
|
1026
|
+
},
|
|
1027
|
+
{
|
|
1028
|
+
maxRetries: LLM_MAX_RETRIES,
|
|
1029
|
+
initialDelay: LLM_RETRY_INITIAL_DELAY_MS,
|
|
1030
|
+
maxDelay: LLM_RETRY_MAX_DELAY_MS,
|
|
1031
|
+
label,
|
|
1032
|
+
signal,
|
|
1033
|
+
isTransientError: (err) => isRetriableLlmError(err, signal),
|
|
1034
|
+
},
|
|
1035
|
+
);
|
|
1036
|
+
|
|
1037
|
+
// Track usage once, on the successful attempt (failed attempts throw before here).
|
|
1038
|
+
recordLlmUsage(model, (response as any).usage, { component: args.component, complexity, observer });
|
|
1039
|
+
return responseText;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Produce the final report from the full corpus, in one pass when it fits and a bounded
|
|
1044
|
+
* reduce when it does not.
|
|
1045
|
+
*
|
|
1046
|
+
* The reduce exists because this is the only call whose input is unbounded, and its
|
|
1047
|
+
* failure mode is the worst in the system: the provider rejects an over-window request
|
|
1048
|
+
* after the whole run has been paid for, and the user receives a mechanical
|
|
1049
|
+
* concatenation of raw reports. Partitioning trades one guaranteed-to-fit pass per
|
|
1050
|
+
* partition plus a merge for that cliff.
|
|
1051
|
+
*/
|
|
1052
|
+
private async synthesizeCorpus(args: {
|
|
1053
|
+
model: Model<any>;
|
|
1054
|
+
apiKey: string;
|
|
1055
|
+
headers?: Record<string, string | null>;
|
|
1056
|
+
signal?: AbortSignal;
|
|
1057
|
+
sessionId: string;
|
|
1058
|
+
config: Config;
|
|
1059
|
+
complexity: 1 | 2 | 3;
|
|
1060
|
+
observer?: UpdatePlanOptions['observer'];
|
|
1061
|
+
reports: Map<string, string>;
|
|
1062
|
+
runContext: string;
|
|
1063
|
+
populate: (partialSection: string) => string;
|
|
1064
|
+
}): Promise<string> {
|
|
1065
|
+
const { config, reports, runContext, populate } = args;
|
|
1066
|
+
|
|
1067
|
+
// Normalize citations across all reports to ONE global numbering BEFORE the synthesis
|
|
1068
|
+
// LLM sees them, and hand it the matching Global Source List. The synthesizer prompt
|
|
1069
|
+
// promises exactly this ("reports have already been normalized to these global
|
|
1070
|
+
// numbers"); doing it here is what makes the synthesized body's [N] line up with the
|
|
1071
|
+
// final CITED LINKS list that ensureCitedLinks() regenerates from the same
|
|
1072
|
+
// normalization. Done once, before partitioning, so every partial pass sees the same
|
|
1073
|
+
// global numbering as the merge.
|
|
1074
|
+
const { normalizedReports, globalCitations } = normalizeCitations(reports);
|
|
1075
|
+
const globalSourceList = globalCitations.length > 0
|
|
1076
|
+
? 'GLOBAL SOURCE LIST (use these exact [N] numbers for every inline citation):\n' +
|
|
1077
|
+
globalCitations
|
|
1078
|
+
.map((c) => `[${c.id}] ${c.url}${c.source ? ` [Source: ${c.source}]` : ''}${c.description ? ` — ${c.description}` : ''}`)
|
|
1079
|
+
.join('\n')
|
|
1080
|
+
: '';
|
|
1081
|
+
|
|
1082
|
+
const budgetChars = synthesisCorpusBudgetChars(
|
|
1083
|
+
args.model,
|
|
1084
|
+
config.SYNTHESIS_MAX_TOKENS,
|
|
1085
|
+
globalSourceList.length + runContext.length,
|
|
1086
|
+
);
|
|
1087
|
+
|
|
1088
|
+
let entries: Array<[string, string]> = Array.from(normalizedReports.entries())
|
|
1089
|
+
.map(([id, report]) => [`Researcher ${id}`, truncateToBudget(report, budgetChars, id)] as [string, string]);
|
|
1090
|
+
|
|
1091
|
+
let pass = 0;
|
|
1092
|
+
let reduced = false;
|
|
1093
|
+
while (pass < MAX_SYNTHESIS_REDUCE_PASSES) {
|
|
1094
|
+
const partitions = partitionCorpus(entries, entrySize, budgetChars);
|
|
1095
|
+
if (partitions.length <= 1) break;
|
|
1096
|
+
reduced = true;
|
|
1097
|
+
logger.warn(
|
|
1098
|
+
`[PlanningService] Synthesis corpus (${entries.reduce((n, e) => n + entrySize(e), 0)} chars) exceeds the ` +
|
|
1099
|
+
`${budgetChars}-char budget for ${args.model.id}; reducing in ${partitions.length} partial passes (pass ${pass + 1})`,
|
|
1100
|
+
);
|
|
1101
|
+
// Budget each partial's OUTPUT so that all of them together fit the next pass's
|
|
1102
|
+
// input. Left at the full synthesis ceiling, N partials can total N x 32k tokens —
|
|
1103
|
+
// larger than the corpus they replaced — so the reduce would need pass after pass to
|
|
1104
|
+
// converge and could still run out of passes. Dividing the budget makes one pass
|
|
1105
|
+
// sufficient in practice. Floored so a wide fan-out cannot squeeze a partial down to
|
|
1106
|
+
// uselessly few tokens; if the floor binds, the extra passes below absorb it.
|
|
1107
|
+
const partialMaxTokens = Math.min(
|
|
1108
|
+
config.SYNTHESIS_MAX_TOKENS,
|
|
1109
|
+
Math.max(
|
|
1110
|
+
MIN_PARTIAL_SYNTHESIS_TOKENS,
|
|
1111
|
+
Math.floor(budgetChars / CHARS_PER_TOKEN / partitions.length),
|
|
1112
|
+
),
|
|
1113
|
+
);
|
|
1114
|
+
const next: Array<[string, string]> = [];
|
|
1115
|
+
for (const [index, partition] of partitions.entries()) {
|
|
1116
|
+
const text = await this.callLead({
|
|
1117
|
+
...args,
|
|
1118
|
+
systemPrompt: populate(partialSynthesisSection(index + 1, partitions.length)),
|
|
1119
|
+
userMessage: buildSynthesisMessage(partition, globalSourceList, runContext, true),
|
|
1120
|
+
maxTokens: partialMaxTokens,
|
|
1121
|
+
component: 'synthesizer',
|
|
1122
|
+
label: `synthesizer-reduce-${pass + 1}.${index + 1}`,
|
|
1123
|
+
});
|
|
1124
|
+
next.push([`Partial synthesis ${index + 1}`, unwrapPartialSynthesis(text, this)]);
|
|
1125
|
+
}
|
|
1126
|
+
entries = next;
|
|
1127
|
+
pass++;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// Exiting on the pass cap with the corpus still over budget means the request below is
|
|
1131
|
+
// knowingly oversized. Say so: a silent truncation-or-overflow here reads downstream as
|
|
1132
|
+
// "the model refused", and the run has already been paid for by this point.
|
|
1133
|
+
const finalSize = entries.reduce((n, e) => n + entrySize(e), 0);
|
|
1134
|
+
if (finalSize > budgetChars) {
|
|
1135
|
+
logger.warn(
|
|
1136
|
+
`[PlanningService] Synthesis corpus is still ${finalSize} chars after ` +
|
|
1137
|
+
`${MAX_SYNTHESIS_REDUCE_PASSES} reduce pass(es), over the ${budgetChars}-char budget for ` +
|
|
1138
|
+
`${args.model.id}. Sending it anyway — the provider may reject this request.`,
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// budgetChars alone can UNDER-report overflow risk once it hits MIN_SYNTHESIS_CORPUS_CHARS:
|
|
1143
|
+
// below that point it is a fixed floor, not a value derived from the window, so a reduced
|
|
1144
|
+
// corpus that fits comfortably under the floor can still combine with a large global source
|
|
1145
|
+
// list and/or run context (neither bounded, both already subtracted from budgetChars but not
|
|
1146
|
+
// from THIS check) to overflow the real window — the same failure class the budget exists to
|
|
1147
|
+
// prevent, just triggered by those two inputs growing instead of the corpus. Check the actual
|
|
1148
|
+
// total request against the actual window directly, independent of any floor.
|
|
1149
|
+
const window = args.model.contextWindow ?? DEFAULT_MODEL_CONTEXT_WINDOW;
|
|
1150
|
+
const estimatedTotalTokens =
|
|
1151
|
+
(finalSize + globalSourceList.length + runContext.length) / CHARS_PER_TOKEN +
|
|
1152
|
+
config.SYNTHESIS_MAX_TOKENS + SYNTHESIS_OVERHEAD_TOKENS;
|
|
1153
|
+
if (estimatedTotalTokens > window) {
|
|
1154
|
+
logger.warn(
|
|
1155
|
+
`[PlanningService] Synthesis request for ${args.model.id} is an estimated ` +
|
|
1156
|
+
`${Math.round(estimatedTotalTokens)} tokens against a ${window}-token context window ` +
|
|
1157
|
+
`(corpus ${finalSize} chars, source list ${globalSourceList.length} chars, run context ` +
|
|
1158
|
+
`${runContext.length} chars, output ceiling ${config.SYNTHESIS_MAX_TOKENS} tokens) — the ` +
|
|
1159
|
+
`source list and/or run context are large enough on their own to overflow the window even ` +
|
|
1160
|
+
`though the reduced corpus fits its budget. Sending it anyway — the provider may reject ` +
|
|
1161
|
+
`this request.`,
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
return this.callLead({
|
|
1166
|
+
...args,
|
|
1167
|
+
systemPrompt: populate(reduced ? MERGE_SYNTHESIS_SECTION : ''),
|
|
1168
|
+
userMessage: buildSynthesisMessage(entries, globalSourceList, runContext, false),
|
|
1169
|
+
maxTokens: config.SYNTHESIS_MAX_TOKENS,
|
|
1170
|
+
component: 'synthesizer',
|
|
1171
|
+
label: 'synthesizer-final',
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
|
|
685
1175
|
/**
|
|
686
1176
|
* Generate queries for a researcher
|
|
687
1177
|
*/
|
|
@@ -689,3 +1179,188 @@ export class PlanningService implements IPlanningService {
|
|
|
689
1179
|
return options.researcher.queries || [];
|
|
690
1180
|
}
|
|
691
1181
|
}
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* Take the prose out of a partial pass's response, whatever shape it arrived in.
|
|
1185
|
+
*
|
|
1186
|
+
* A partial pass is told to return plain text, and the override that says so is now the
|
|
1187
|
+
* last thing in its prompt. A model can still return the synthesis envelope out of habit —
|
|
1188
|
+
* and if it does, the merge pass would receive a raw JSON blob where it expects prose,
|
|
1189
|
+
* then dutifully reproduce the braces in the final report. Unwrapping here costs one
|
|
1190
|
+
* parse attempt and makes the reduce independent of that going right.
|
|
1191
|
+
*/
|
|
1192
|
+
function unwrapPartialSynthesis(text: string, service: { parseJsonPlan(t: string): ResearchPlan }): string {
|
|
1193
|
+
const trimmed = text.trim();
|
|
1194
|
+
if (!trimmed.includes('"content"')) return trimmed;
|
|
1195
|
+
try {
|
|
1196
|
+
const parsed = service.parseJsonPlan(trimmed);
|
|
1197
|
+
const content = typeof parsed.content === 'string' ? parsed.content.trim() : '';
|
|
1198
|
+
if (content) {
|
|
1199
|
+
logger.debug('[PlanningService] Partial synthesis returned a JSON envelope; unwrapped its content');
|
|
1200
|
+
return content;
|
|
1201
|
+
}
|
|
1202
|
+
} catch {
|
|
1203
|
+
// Not an envelope — it is prose that happens to mention "content". Use it as written.
|
|
1204
|
+
}
|
|
1205
|
+
return trimmed;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/** Chars a corpus entry contributes, including its `### <label>` heading line. */
|
|
1209
|
+
function entrySize(entry: [string, string]): number {
|
|
1210
|
+
return entry[1].length + entry[0].length + 8;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* Cut a single report that is larger than the entire corpus budget.
|
|
1215
|
+
*
|
|
1216
|
+
* Reached only when one researcher produced more text than the model can hold at once, at
|
|
1217
|
+
* which point there is no partitioning left to do — the choice is a truncated report or a
|
|
1218
|
+
* rejected request. The marker is left in the text so the model knows the tail is missing
|
|
1219
|
+
* rather than treating the cut as the end of the findings.
|
|
1220
|
+
*/
|
|
1221
|
+
function truncateToBudget(report: string, budgetChars: number, id: string): string {
|
|
1222
|
+
if (report.length <= budgetChars) return report;
|
|
1223
|
+
logger.warn(
|
|
1224
|
+
`[PlanningService] Report ${id} (${report.length} chars) exceeds the whole synthesis corpus budget ` +
|
|
1225
|
+
`(${budgetChars}); truncating it — the tail of this report will not reach the final report`,
|
|
1226
|
+
);
|
|
1227
|
+
return `${report.slice(0, budgetChars)}\n\n[report truncated: exceeded the synthesis context budget]`;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* Which reports the router has not read in full yet.
|
|
1232
|
+
*
|
|
1233
|
+
* A report is FRESH on the one round it arrives, and thereafter is represented by its
|
|
1234
|
+
* digest — the router already read it, and re-sending it is the repetition this protocol
|
|
1235
|
+
* exists to remove. Derived from the `${round}.${id}` key the synthesis service stores
|
|
1236
|
+
* under, so it needs no extra state and survives a `wait` retry re-running the same round.
|
|
1237
|
+
*
|
|
1238
|
+
* An id with no parseable round prefix is treated as FRESH. That errs toward showing the
|
|
1239
|
+
* router more evidence: the failure mode of guessing wrong in the other direction is a
|
|
1240
|
+
* decision made without ever having seen a finding.
|
|
1241
|
+
*/
|
|
1242
|
+
export function splitReportsByRound(
|
|
1243
|
+
reports: ReadonlyMap<string, string>,
|
|
1244
|
+
currentRound: number,
|
|
1245
|
+
): { freshIds: string[]; priorIds: string[] } {
|
|
1246
|
+
const freshIds: string[] = [];
|
|
1247
|
+
const priorIds: string[] = [];
|
|
1248
|
+
for (const id of reports.keys()) {
|
|
1249
|
+
const round = Number.parseInt(id.split('.')[0] ?? '', 10);
|
|
1250
|
+
// Reports produced during round `currentRound - 1` are the ones this call is judging.
|
|
1251
|
+
// `>=` rather than `===` so a round that produced nothing cannot strand a later
|
|
1252
|
+
// report in the "already reviewed" bucket it was never shown in.
|
|
1253
|
+
if (!Number.isFinite(round) || round >= currentRound - 1) freshIds.push(id);
|
|
1254
|
+
else priorIds.push(id);
|
|
1255
|
+
}
|
|
1256
|
+
return { freshIds, priorIds };
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* Drop the trailing CITED LINKS section from a report body.
|
|
1261
|
+
*
|
|
1262
|
+
* Routing needs the findings, not the bibliography — and the bibliography is a large share
|
|
1263
|
+
* of a report, since the researcher prompt asks for 3–6 dense sentences per source. The
|
|
1264
|
+
* digest already carries the source count, which is the only part of it a routing decision
|
|
1265
|
+
* uses. Anchored on a real line-leading header, never an incidental mention in prose.
|
|
1266
|
+
*/
|
|
1267
|
+
function stripCitedLinks(report: string): string {
|
|
1268
|
+
const { lineStart } = lastCitedLinksHeader(report);
|
|
1269
|
+
return (lineStart >= 0 ? report.slice(0, lineStart) : report).trim();
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* The router's user message.
|
|
1274
|
+
*
|
|
1275
|
+
* Earlier rounds come FIRST and are append-only — round r's block is a byte-identical
|
|
1276
|
+
* prefix of round r+1's — so the stable part of the request sits where a prompt cache can
|
|
1277
|
+
* hold it, and the volatile new reports sit after it.
|
|
1278
|
+
*
|
|
1279
|
+
* Each fresh report is shown with the digest its researcher wrote for it. That pairing is
|
|
1280
|
+
* the point: a self-reported "Gaps: none" sitting next to thin prose is visible as a
|
|
1281
|
+
* mismatch, where a digest on its own has to be taken on trust.
|
|
1282
|
+
*/
|
|
1283
|
+
export function buildRouterMessage(
|
|
1284
|
+
fresh: ReadonlyMap<string, { body: string; digest: string }>,
|
|
1285
|
+
priorDigests: ReadonlyMap<string, string>,
|
|
1286
|
+
runContext: string,
|
|
1287
|
+
): string {
|
|
1288
|
+
const priorBlock = priorDigests.size > 0
|
|
1289
|
+
? `## REVIEWED IN EARLIER ROUNDS\nYou read these reports in full when they arrived and already judged them. Only their coverage digests are repeated here.\n\n${formatDigestsForRouter(priorDigests)}`
|
|
1290
|
+
: '';
|
|
1291
|
+
|
|
1292
|
+
const freshBlock = fresh.size > 0
|
|
1293
|
+
? `## NEW SINCE YOUR LAST DECISION — FULL REPORTS\nJudge these on the evidence in the reports themselves. Each carries the digest its researcher wrote; treat that as a claim to check against the report below it, not as a fact.\n\n${
|
|
1294
|
+
Array.from(fresh.entries())
|
|
1295
|
+
.map(([id, r]) => `### Researcher ${id}\nResearcher's coverage claim:\n${r.digest}\n\nReport:\n${r.body || '(this researcher produced no usable report)'}`)
|
|
1296
|
+
.join('\n\n')
|
|
1297
|
+
}`
|
|
1298
|
+
: '(No new reports since your last decision.)';
|
|
1299
|
+
|
|
1300
|
+
return [
|
|
1301
|
+
'Findings from the research team follow.',
|
|
1302
|
+
priorBlock,
|
|
1303
|
+
freshBlock,
|
|
1304
|
+
runContext,
|
|
1305
|
+
'Decide whether the research is complete or another round is needed. Return JSON only.',
|
|
1306
|
+
].filter(Boolean).join('\n\n---\n\n');
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/** The synthesizer's user message: corpus, global source list, run context, directive. */
|
|
1310
|
+
function buildSynthesisMessage(
|
|
1311
|
+
entries: Array<[string, string]>,
|
|
1312
|
+
globalSourceList: string,
|
|
1313
|
+
runContext: string,
|
|
1314
|
+
isPartialPass: boolean,
|
|
1315
|
+
): string {
|
|
1316
|
+
const isMerge = entries.some(([label]) => label.startsWith('Partial synthesis'));
|
|
1317
|
+
const leadIn = isMerge
|
|
1318
|
+
? 'Partial syntheses of the research findings follow.'
|
|
1319
|
+
: 'Findings from the research team follow.';
|
|
1320
|
+
const body = entries.map(([label, text]) => `### ${label}\n${text}`).join('\n\n');
|
|
1321
|
+
const directive = isPartialPass
|
|
1322
|
+
? 'Write the partial synthesis for the findings above, as plain text.'
|
|
1323
|
+
: 'Write the final report now, based on the findings above.';
|
|
1324
|
+
return [leadIn, body, globalSourceList, runContext, directive]
|
|
1325
|
+
.filter(Boolean)
|
|
1326
|
+
.join('\n\n---\n\n');
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* Overrides the synthesizer's output contract for one partition of a reduce pass.
|
|
1331
|
+
*
|
|
1332
|
+
* Interpolated at the very END of the template, after the Output Requirements block, so
|
|
1333
|
+
* this is the last instruction the model reads. Placed earlier it loses: the JSON-only
|
|
1334
|
+
* format rule further down would be the final word, the model would return an envelope
|
|
1335
|
+
* instead of prose, and the merge pass would receive raw JSON blobs where it expects
|
|
1336
|
+
* partial syntheses.
|
|
1337
|
+
*/
|
|
1338
|
+
function partialSynthesisSection(index: number, total: number): string {
|
|
1339
|
+
return [
|
|
1340
|
+
'---',
|
|
1341
|
+
'',
|
|
1342
|
+
'## PARTIAL PASS — read this LAST; it OVERRIDES the Output Requirements and CITED LINKS rules above',
|
|
1343
|
+
'',
|
|
1344
|
+
`The run's findings did not fit in one request. You are being given partition ${index} of ${total}.`,
|
|
1345
|
+
'',
|
|
1346
|
+
'- Write an exhaustive, topic-organized synthesis of ONLY the findings you were given. Every rule about detail, grounding and anonymity still applies at full strength — a later pass merges your output, so anything you omit is lost for good.',
|
|
1347
|
+
'- Use the global [N] numbers from the Global Source List for every inline citation, exactly as instructed above.',
|
|
1348
|
+
'- Do NOT write a CITED LINKS section. The merge pass writes the single one.',
|
|
1349
|
+
'- Do NOT return JSON. Return the synthesis as plain text and nothing else.',
|
|
1350
|
+
'- Do NOT mention partitioning, or that other findings exist.',
|
|
1351
|
+
].join('\n');
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
/** Tells the final pass that its inputs are partial syntheses rather than raw reports. */
|
|
1355
|
+
const MERGE_SYNTHESIS_SECTION = [
|
|
1356
|
+
'---',
|
|
1357
|
+
'',
|
|
1358
|
+
'## MERGE PASS',
|
|
1359
|
+
'',
|
|
1360
|
+
'The material above is a set of partial syntheses of this run\'s findings, each covering a different part of it. Merge them into one report.',
|
|
1361
|
+
'',
|
|
1362
|
+
'- Reorganize across the partials BY TOPIC. Do not present them in sequence, and do not label or reference them.',
|
|
1363
|
+
'- Keep every fact, date, name and statistic from every partial. Merging is not summarizing.',
|
|
1364
|
+
'- Reconcile overlap: where two partials cover the same ground, state it once, keeping the citations from both.',
|
|
1365
|
+
'- All other rules above — including the CITED LINKS section and the JSON output format — apply to your output as normal.',
|
|
1366
|
+
].join('\n');
|