@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.
Files changed (86) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.mjs +5670 -4705
  3. package/dist/prompts/research-tool-usage.md +1 -1
  4. package/dist/prompts/researcher.md +1 -0
  5. package/dist/prompts/system-lead-router.md +122 -0
  6. package/dist/prompts/system-lead-synthesizer.md +46 -0
  7. package/dist/thread-worker.mjs +169 -62
  8. package/docs/ARCHITECTURE.md +18 -10
  9. package/docs/CHANGELOG.md +225 -0
  10. package/docs/CONFIGURATION.md +106 -7
  11. package/package.json +2 -2
  12. package/scripts/build.cjs +8 -1
  13. package/skills/pi-research/SKILL.md +1 -1
  14. package/src/cli.ts +5 -1
  15. package/src/config.ts +22 -12
  16. package/src/constants.ts +1 -1
  17. package/src/core/interfaces/observer-interfaces.ts +3 -3
  18. package/src/core/interfaces/orchestration-interfaces.ts +2 -0
  19. package/src/core/interfaces/planning-interfaces.ts +7 -1
  20. package/src/core/interfaces/scheduler-interfaces.ts +18 -2
  21. package/src/core/llm/agentic-repair.ts +7 -2
  22. package/src/core/llm/inject-date.ts +4 -1
  23. package/src/core/llm/llm-utils.ts +7 -0
  24. package/src/core/planning-service.ts +766 -91
  25. package/src/core/planning-utils.ts +21 -5
  26. package/src/core/service-initialization.ts +19 -3
  27. package/src/healthcheck/index.ts +11 -2
  28. package/src/index.ts +4 -3
  29. package/src/infrastructure/browser/browser-client.ts +68 -34
  30. package/src/infrastructure/browser/browser-task-scheduler.ts +236 -43
  31. package/src/infrastructure/browser/config.ts +79 -1
  32. package/src/infrastructure/browser/priority-task-queue.ts +55 -1
  33. package/src/infrastructure/browser/scheduler-factory.ts +12 -1
  34. package/src/infrastructure/browser/task-execution-service.ts +5 -5
  35. package/src/infrastructure/browser/thread-worker-browser.ts +87 -36
  36. package/src/infrastructure/browser/thread-worker-messaging.ts +99 -10
  37. package/src/infrastructure/browser/thread-worker.mjs +169 -62
  38. package/src/infrastructure/browser/thread-worker.ts +13 -5
  39. package/src/infrastructure/browser/worker-pool-manager.ts +127 -12
  40. package/src/infrastructure/embedding/embedding-client.ts +14 -4
  41. package/src/infrastructure/embedding/embedding-factory.ts +83 -15
  42. package/src/infrastructure/embedding/embedding-server.ts +34 -0
  43. package/src/infrastructure/file-lock-service.ts +214 -19
  44. package/src/infrastructure/knowledge-store-service.ts +14 -0
  45. package/src/infrastructure/research-run-semaphore.ts +1 -1
  46. package/src/infrastructure/service-initialization.ts +7 -0
  47. package/src/knowledge/embedder-init.ts +46 -19
  48. package/src/knowledge/embedder.ts +11 -3
  49. package/src/knowledge/index.ts +36 -10
  50. package/src/knowledge/store.ts +29 -5
  51. package/src/logger.ts +11 -0
  52. package/src/orchestration/deep-research-orchestrator.ts +143 -87
  53. package/src/orchestration/quick-research-orchestrator.ts +11 -2
  54. package/src/orchestration/research-orchestration-service.ts +8 -0
  55. package/src/orchestration/research-synthesis-service.ts +53 -1
  56. package/src/orchestration/researcher-executor.ts +53 -2
  57. package/src/orchestration/researcher.ts +15 -2
  58. package/src/orchestration/session-state.ts +16 -0
  59. package/src/prompts/research-tool-usage.md +1 -1
  60. package/src/prompts/researcher.md +1 -0
  61. package/src/prompts/system-lead-router.md +122 -0
  62. package/src/prompts/system-lead-synthesizer.md +46 -0
  63. package/src/sdk.ts +10 -0
  64. package/src/skill-install/skill-installer.ts +5 -1
  65. package/src/stackexchange/index.ts +3 -0
  66. package/src/stackexchange/rest-client.ts +7 -12
  67. package/src/tools/research-knowledge-search.ts +11 -3
  68. package/src/tools/research-tool-definition.ts +38 -7
  69. package/src/tools/scrape.ts +6 -0
  70. package/src/tools/search.ts +7 -0
  71. package/src/tools/security.ts +31 -10
  72. package/src/tools/stackexchange.ts +13 -7
  73. package/src/tools/youtube-transcript.ts +26 -12
  74. package/src/utils/cancellation.ts +36 -0
  75. package/src/utils/coverage-digest.ts +204 -0
  76. package/src/utils/host-config.ts +12 -1
  77. package/src/utils/llm-usage.ts +33 -6
  78. package/src/utils/log-utils.ts +10 -0
  79. package/src/utils/metrics-summary.ts +20 -4
  80. package/src/utils/metrics.ts +27 -1
  81. package/src/web-research/browser-search.ts +102 -22
  82. package/src/web-research/web-scraper.ts +27 -16
  83. package/src/youtube/potoken.ts +8 -1
  84. package/src/youtube/transcript-client.ts +56 -5
  85. package/dist/prompts/system-lead-evaluator.md +0 -118
  86. 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
- if (plan.action !== 'synthesize') {
385
- plan.action = 'delegate';
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
- logger.log(`[PlanningService] Evaluating Round ${round} findings for: "${query}"`);
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-evaluator');
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
- const complexityGuidance = this.getEvaluatorComplexityGuidance(complexity);
444
- const roundPhaseGuidance = this.getRoundPhaseGuidance(round, maxRounds, complexity, maxTeamSize);
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
- const systemPrompt = injectCurrentDate(promptTemplate, 'evaluator');
468
- const populatedPrompt = this.populatePrompt(systemPrompt, {
469
- root_query: query,
470
- round_number: round,
471
- max_rounds: maxRounds,
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
- // Normalize citations across all reports to ONE global numbering BEFORE the
485
- // evaluator/synthesis LLM sees them, and hand it the matching Global Source
486
- // List. The evaluator prompt promises exactly this ("reports have already
487
- // been normalized to these global numbers"); doing it here is what makes the
488
- // synthesized body's [N] line up with the final CITED LINKS list that
489
- // ensureCitedLinks() regenerates from the same normalization.
490
- const { normalizedReports, globalCitations } = normalizeCitations(reports);
491
- const globalSourceList = globalCitations.length > 0
492
- ? 'GLOBAL SOURCE LIST (use these exact [N] numbers for every inline citation):\n' +
493
- globalCitations
494
- .map((c) => `[${c.id}] ${c.url}${c.source ? ` [Source: ${c.source}]` : ''}${c.description ? ` — ${c.description}` : ''}`)
495
- .join('\n') +
496
- '\n\n---\n\n'
497
- : '';
498
- const findings = globalSourceList + Array.from(normalizedReports.entries())
499
- .map(([id, report]) => `### Researcher ${id}\n${report}`)
500
- .join('\n\n');
501
-
502
- const userMessage = mustSynthesize
503
- ? `Research budget exhausted. Synthesize final report now based on findings:\n\n${findings}`
504
- : `Evaluate the following findings and decide next steps (delegate more researchers or synthesize final report):\n\n${findings}`;
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 llmTimeout = config.LLM_TIMEOUT_MS;
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
- // Retry the transport call + validation on transient failures (see generatePlan for the
515
- // rationale — a "terminated" abort only throws at validateAndExtractText, so validation
516
- // must be inside the retried region; repair/fallback stays outside).
517
- const { response, responseText } = await retryWithBackoff(
518
- async () => {
519
- const response = await withTimeout(
520
- completeSimple(model, {
521
- systemPrompt: populatedPrompt,
522
- messages: [
523
- { role: 'user', content: [{ type: 'text', text: userMessage }], timestamp: Date.now() },
524
- ],
525
- // The evaluator's response carries the final report whenever it synthesizes —
526
- // which it may choose to do on ANY round (voluntary early finish), not just the
527
- // forced final round. So every evaluator call gets the full synthesis budget; a
528
- // small decision response simply uses a fraction of it. Clamped to the model.
529
- }, buildSafeOptions(model, {
530
- apiKey: authResult.apiKey || '',
531
- headers: authResult.headers,
532
- signal
533
- }, config.SYNTHESIS_MAX_TOKENS, config.LLM_THINKING_LEVEL)),
534
- llmTimeout, 'evaluator-updatePlanForRound',
535
- );
536
- const responseText = validateAndExtractText(response, 'Evaluator');
537
- return { response, responseText };
538
- },
539
- {
540
- maxRetries: LLM_MAX_RETRIES,
541
- initialDelay: LLM_RETRY_INITIAL_DELAY_MS,
542
- maxDelay: LLM_RETRY_MAX_DELAY_MS,
543
- label: 'evaluator-updatePlanForRound',
544
- signal,
545
- isTransientError: (err) => isRetriableLlmError(err, signal),
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('[PlanningService] Evaluation JSON malformed, attempting agentic repair');
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 evaluation for: ${query} (Round ${round})`,
767
+ context: `Research ${role} for: ${query} (Round ${round})`,
566
768
  serviceName: 'PlanningService',
567
769
  signal,
568
- maxTokens: config.SYNTHESIS_MAX_TOKENS,
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: 'evaluator', complexity, observer }),
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 evaluation on the final round; synthesizing from raw text');
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] Evaluation unparseable mid-research; continuing the prior agenda rather than synthesizing early');
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
- logger.warn('[PlanningService] Evaluation unparseable and no prior agenda to continue; falling back to synthesize');
599
- const safeContent = salvageReportText(responseText);
600
- plan = { action: 'synthesize', content: safeContent, researchers: [] };
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');