@lincoln504/pi-research 1.3.12 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.mjs +5495 -4645
  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 +94 -19
  8. package/docs/ARCHITECTURE.md +18 -10
  9. package/docs/CHANGELOG.md +149 -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 +12 -8
  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 +719 -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 +220 -34
  31. package/src/infrastructure/browser/config.ts +41 -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 +32 -2
  36. package/src/infrastructure/browser/thread-worker-messaging.ts +38 -6
  37. package/src/infrastructure/browser/thread-worker.mjs +94 -19
  38. package/src/infrastructure/browser/thread-worker.ts +2 -2
  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/file-lock-service.ts +214 -19
  43. package/src/infrastructure/knowledge-store-service.ts +14 -0
  44. package/src/infrastructure/research-run-semaphore.ts +1 -1
  45. package/src/infrastructure/service-initialization.ts +7 -0
  46. package/src/knowledge/embedder-init.ts +43 -17
  47. package/src/logger.ts +11 -0
  48. package/src/orchestration/deep-research-orchestrator.ts +141 -87
  49. package/src/orchestration/quick-research-orchestrator.ts +11 -2
  50. package/src/orchestration/research-orchestration-service.ts +8 -0
  51. package/src/orchestration/research-synthesis-service.ts +53 -1
  52. package/src/orchestration/researcher-executor.ts +53 -2
  53. package/src/orchestration/researcher.ts +15 -2
  54. package/src/prompts/research-tool-usage.md +1 -1
  55. package/src/prompts/researcher.md +1 -0
  56. package/src/prompts/system-lead-router.md +122 -0
  57. package/src/prompts/system-lead-synthesizer.md +46 -0
  58. package/src/tools/research-knowledge-search.ts +11 -3
  59. package/src/tools/scrape.ts +6 -0
  60. package/src/tools/search.ts +7 -0
  61. package/src/tools/security.ts +27 -9
  62. package/src/tools/stackexchange.ts +13 -7
  63. package/src/tools/youtube-transcript.ts +26 -12
  64. package/src/utils/cancellation.ts +36 -0
  65. package/src/utils/coverage-digest.ts +204 -0
  66. package/src/utils/llm-usage.ts +33 -6
  67. package/src/utils/metrics-summary.ts +20 -4
  68. package/src/utils/metrics.ts +27 -1
  69. package/src/web-research/browser-search.ts +93 -20
  70. package/dist/prompts/system-lead-evaluator.md +0 -118
  71. package/src/prompts/system-lead-evaluator.md +0 -118
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Coverage digests — the routing half of the evaluator's input.
3
+ *
4
+ * A researcher emits a short, structured account of what it covered at the HEAD of its
5
+ * report, delimited by `COVERAGE DIGEST` / `END COVERAGE DIGEST`. The digest is split off
6
+ * when the report is stored, so every consumer of a report body sees one without routing
7
+ * metadata in it. The router reads a fresh report in full WITH its digest beside it, and
8
+ * represents every earlier round by digest alone; the synthesizer reads bodies only.
9
+ *
10
+ * Why the head and not the tail: the tail already belongs to `CITED LINKS`, and a report
11
+ * that gets truncated mid-write (timeout, token ceiling — see researcher-executor's
12
+ * partial-report path) loses its tail first. A digest at the head survives truncation,
13
+ * which is exactly the case where the router most needs to know what was and wasn't
14
+ * established.
15
+ *
16
+ * Everything here is defensive: a missing, unterminated, or oversized digest must degrade
17
+ * to a usable router input, never to a lost report body. The report is the run's product;
18
+ * the digest is an optimization.
19
+ */
20
+
21
+ import { parseCitations, lastCitedLinksHeaderIndex } from './text-utils.ts';
22
+
23
+ /** Opening delimiter, matched line-leading and case-insensitively. */
24
+ export const COVERAGE_DIGEST_HEADER = 'COVERAGE DIGEST';
25
+
26
+ /** Closing delimiter. Required — see `splitCoverageDigest`. */
27
+ export const COVERAGE_DIGEST_TERMINATOR = 'END COVERAGE DIGEST';
28
+
29
+ /**
30
+ * How far into the report the opening delimiter may appear and still count.
31
+ *
32
+ * The digest is specified to be the first thing in the report, but models routinely emit
33
+ * a short preamble ("Here is my report:") ahead of it. This window tolerates that while
34
+ * refusing to match the phrase occurring incidentally in the middle of prose — a report
35
+ * ABOUT research methodology could well discuss a "coverage digest", and treating that as
36
+ * a delimiter would silently truncate everything before it out of the synthesis corpus.
37
+ */
38
+ const DIGEST_HEAD_WINDOW_CHARS = 600;
39
+
40
+ /**
41
+ * Ceiling on the digest text handed to the router.
42
+ *
43
+ * A well-formed digest is a handful of lines. A digest much larger than this means the
44
+ * model wrote its findings inside the delimiters, which would reintroduce exactly the
45
+ * unbounded routing input this protocol exists to remove. The body is unaffected — it is
46
+ * whatever follows the terminator either way — so capping here costs nothing but router
47
+ * detail.
48
+ */
49
+ export const MAX_DIGEST_CHARS = 2400;
50
+
51
+ /**
52
+ * Terminator must appear within this distance of the header for the block to be treated
53
+ * as a digest. Bounds the damage from a model that writes the opening delimiter, forgets
54
+ * the closing one, and happens to write `END COVERAGE DIGEST` much later (or from a
55
+ * report that quotes both delimiters while discussing this very protocol).
56
+ */
57
+ const MAX_DIGEST_BLOCK_CHARS = 8000;
58
+
59
+ /**
60
+ * The `{{digest_section}}` block of the researcher prompt, for a run that has a research
61
+ * lead to route (depth 1+).
62
+ *
63
+ * Left EMPTY for quick research: that mode has no lead and no second round, its report goes
64
+ * straight to the user, and asking for routing metadata there would spend output tokens on
65
+ * a reader that does not exist — while telling the model about a "research lead" that is
66
+ * not part of its run.
67
+ */
68
+ export const RESEARCHER_DIGEST_SECTION = `Your report has three parts in this exact order: a COVERAGE DIGEST block, the report body, and the CITED LINKS list.
69
+
70
+ ### Part 1 — COVERAGE DIGEST (first, mandatory)
71
+
72
+ Open the report with this block, exactly as shown. The research lead reads it to decide what still needs investigating. On the round your report arrives the lead reads the digest alongside the full report and checks one against the other; on every later round it sees ONLY this digest — so anything you leave out of it is invisible to every decision after the first.
73
+
74
+ ${COVERAGE_DIGEST_HEADER}
75
+ Goal: [your assigned goal, restated in one line]
76
+ Covered: [the specific topics you actually established from sources, separated by semicolons — be concrete, name the things you found out, not the areas you looked at]
77
+ Unsubstantiated: [claims you searched for but could not ground in any source; write "none" if there are none]
78
+ Gaps: [what a follow-up researcher would still need to investigate on this goal; write "none" if your goal is fully covered]
79
+ Sources: [the number of entries in your CITED LINKS list]
80
+ ${COVERAGE_DIGEST_TERMINATOR}
81
+
82
+ Rules for the digest:
83
+ - Keep it under 150 words total. It is an index, not a summary — no [N] citations, no findings prose, no URLs.
84
+ - Be honest about \`Gaps\` and \`Unsubstantiated\`. Claiming full coverage you do not have is the single most damaging thing you can write here: it argues for ending the research on this topic, and after the round it arrives there is no report left for the lead to check the claim against.
85
+ - Write both delimiter lines exactly, in capitals, each on its own line.
86
+
87
+ ### Part 2 — Report body
88
+
89
+ `;
90
+
91
+ /** Line-leading delimiter matcher, tolerating the markdown decoration models add. */
92
+ function delimiterRegex(word: string): RegExp {
93
+ return new RegExp(`(^|\\n)([ \\t]*(?:#{1,6}[ \\t]*|>[ \\t]*|\\*\\*[ \\t]*)?)${word}\\b`, 'i');
94
+ }
95
+
96
+ export interface SplitReport {
97
+ /** Digest text WITHOUT the delimiter lines, trimmed and capped. Empty when absent. */
98
+ digest: string;
99
+ /** The report body with the digest block removed. Never empty unless the input was. */
100
+ body: string;
101
+ }
102
+
103
+ /**
104
+ * Split a researcher report into its coverage digest and its body.
105
+ *
106
+ * Both delimiters are required. A header with no terminator is treated as NO digest and
107
+ * the report is returned intact: the alternative — guessing where the block ends — risks
108
+ * swallowing the findings, and a report that reaches the synthesizer whole is strictly
109
+ * better than one that reaches it short.
110
+ */
111
+ export function splitCoverageDigest(report: string): SplitReport {
112
+ if (!report) return { digest: '', body: report ?? '' };
113
+
114
+ const headerMatch = delimiterRegex(COVERAGE_DIGEST_HEADER).exec(report);
115
+ if (!headerMatch) return { digest: '', body: report };
116
+
117
+ const lineStart = headerMatch.index + headerMatch[1]!.length;
118
+ if (lineStart > DIGEST_HEAD_WINDOW_CHARS) return { digest: '', body: report };
119
+
120
+ // Search for the terminator strictly AFTER the header line, so `END COVERAGE DIGEST`
121
+ // cannot match the header's own trailing text.
122
+ const afterHeaderWord = lineStart + headerMatch[2]!.length + COVERAGE_DIGEST_HEADER.length;
123
+ // Consume the header's WHOLE line, not just the matched word — mirrors the terminator
124
+ // handling below. A model that writes `**COVERAGE DIGEST**` matches on the leading
125
+ // marker; without this, the trailing `**` (or a closing `:`, `##`, etc.) would dangle
126
+ // at the head of the digest text instead of being discarded as delimiter.
127
+ const headerNewline = report.indexOf('\n', afterHeaderWord);
128
+ const afterHeader = headerNewline < 0 ? afterHeaderWord : headerNewline + 1;
129
+ const rest = report.slice(afterHeader);
130
+ const endMatch = delimiterRegex(COVERAGE_DIGEST_TERMINATOR).exec(rest);
131
+ if (!endMatch) return { digest: '', body: report };
132
+
133
+ const endLineStart = endMatch.index + endMatch[1]!.length;
134
+ if (endLineStart > MAX_DIGEST_BLOCK_CHARS) return { digest: '', body: report };
135
+
136
+ const digestRaw = rest.slice(0, endLineStart).trim();
137
+ // Consume the terminator's WHOLE line, not just the matched word: a model that writes
138
+ // `**END COVERAGE DIGEST**` matches on the leading marker and would otherwise leave the
139
+ // trailing `**` dangling at the head of the body. The terminator line is delimiter, so
140
+ // none of it belongs to the report.
141
+ const afterWord = endMatch.index + endMatch[0]!.length;
142
+ const nextNewline = rest.indexOf('\n', afterWord);
143
+ const bodyAfter = nextNewline < 0 ? '' : rest.slice(nextNewline + 1);
144
+
145
+ // Anything the model wrote BEFORE the digest (a preamble) is kept — it is part of the
146
+ // report, and dropping content is never the safe default here.
147
+ const bodyBefore = report.slice(0, lineStart);
148
+ const body = `${bodyBefore}${bodyAfter}`.trim();
149
+
150
+ const digest = digestRaw.length > MAX_DIGEST_CHARS
151
+ ? `${digestRaw.slice(0, MAX_DIGEST_CHARS).trimEnd()}\n(digest truncated)`
152
+ : digestRaw;
153
+
154
+ // A digest is only worth having if a body survives it. If the "body" came out empty the
155
+ // model put the entire report inside the delimiters; return the original so nothing is
156
+ // lost, and let the derived digest below serve routing.
157
+ if (!body) return { digest: '', body: report };
158
+
159
+ return { digest, body };
160
+ }
161
+
162
+ /**
163
+ * Build a minimal digest for a report that carries none.
164
+ *
165
+ * Reached for older/partial reports and for models that ignore the format instruction.
166
+ * The router must never be blind to a researcher that actually ran, so this derives the
167
+ * two facts that can be read off any report mechanically — its opening topic line and how
168
+ * many sources it cited — and says plainly that the rest is unknown. A router told
169
+ * "coverage unknown" delegates; a router told nothing assumes the gap was filled.
170
+ */
171
+ export function deriveCoverageDigest(body: string): string {
172
+ const citationCount = parseCitations(body).length;
173
+
174
+ // The report's first line is its topic title by the researcher prompt's format. Read it
175
+ // from the prose region only — a report that is nothing BUT a CITED LINKS section would
176
+ // otherwise present a URL line as its topic.
177
+ const citedIdx = lastCitedLinksHeaderIndex(body);
178
+ const prose = citedIdx >= 0 ? body.slice(0, citedIdx) : body;
179
+ const firstLine = prose
180
+ .split('\n')
181
+ .map((l) => l.replace(/^[#>*\s]+/, '').trim())
182
+ .find((l) => l.length > 0) ?? '';
183
+
184
+ const topic = firstLine.length > 200 ? `${firstLine.slice(0, 200).trimEnd()}…` : firstLine;
185
+
186
+ return [
187
+ `Covered: ${topic || 'unknown — the report has no readable topic line'}`,
188
+ `Gaps: unknown — this researcher emitted no coverage digest`,
189
+ `Sources: ${citationCount}`,
190
+ ].join('\n');
191
+ }
192
+
193
+ /**
194
+ * Render the router's view of the run: one block per researcher, in report order.
195
+ *
196
+ * Deliberately mirrors the findings block's `### Researcher <id>` shape so the router's
197
+ * researcher ids line up with the ids the synthesizer and the query history already use.
198
+ */
199
+ export function formatDigestsForRouter(digests: ReadonlyMap<string, string>): string {
200
+ if (digests.size === 0) return '';
201
+ return Array.from(digests.entries())
202
+ .map(([id, digest]) => `### Researcher ${id}\n${digest}`)
203
+ .join('\n\n');
204
+ }
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { Model } from '@earendil-works/pi-ai';
15
- import { extractUsage } from '../types/llm.ts';
15
+ import { extractUsage, type TokenUsage } from '../types/llm.ts';
16
16
  import { logger } from '../logger.ts';
17
17
  import { metrics } from './metrics.ts';
18
18
 
@@ -51,12 +51,35 @@ function warnIfUnpriced(model: Model<any>, tokens: number, cost: number): void {
51
51
  );
52
52
  }
53
53
 
54
+ /**
55
+ * Record the prompt-cache split alongside the plain token counter.
56
+ *
57
+ * `llm_tokens_total` folds cached and uncached input into one number, so a run whose
58
+ * prefix cache never hits is indistinguishable from one that hits on every call — the
59
+ * cost line moves, but nothing says why. These two counters are the only signal that
60
+ * prompt caching is working at all, and they are what a caching regression shows up in:
61
+ * a prompt edit that pushes volatile text ahead of stable text drives `cache_read` to
62
+ * zero while `cache_write` keeps climbing.
63
+ *
64
+ * Zero-valued counters are still emitted (when the provider reported the field at all)
65
+ * so "cache is off" and "provider does not report caching" stay distinguishable: the
66
+ * first shows the counter at 0, the second omits it.
67
+ */
68
+ function recordCacheTokens(parsed: Partial<TokenUsage>, labels: Record<string, string>): void {
69
+ if (typeof parsed.cacheRead === 'number') {
70
+ metrics.increment('llm_cache_read_tokens_total', parsed.cacheRead, labels);
71
+ }
72
+ if (typeof parsed.cacheWrite === 'number') {
73
+ metrics.increment('llm_cache_write_tokens_total', parsed.cacheWrite, labels);
74
+ }
75
+ }
76
+
54
77
  /** Structural subset of the run observer — just the token sinks. Kept structural (rather
55
78
  * than importing ResearchObserver from core/) so this foundation-layer helper does not
56
79
  * depend on an upper layer; the full ResearchObserver satisfies it. */
57
80
  export interface TokenSink {
58
81
  onTokensConsumed?: (tokens: number, cost: number) => void;
59
- /** Phase-scoped sinks, fed from the `component` label ('coordinator'/'evaluator');
82
+ /** Phase-scoped sinks, fed from the `component` label ('coordinator', 'router'/'synthesizer');
60
83
  * they drive the TUI coord/eval cost rows and the SDK planning_tokens /
61
84
  * evaluation_tokens events. */
62
85
  onPlanningTokens?: (tokens: number, cost: number) => void;
@@ -64,7 +87,7 @@ export interface TokenSink {
64
87
  }
65
88
 
66
89
  export interface RecordUsageOptions {
67
- /** Metrics `component` label (e.g. 'coordinator', 'evaluator', 'researcher'). */
90
+ /** Metrics `component` label (e.g. 'coordinator', 'router', 'synthesizer', 'researcher'). */
68
91
  component: string;
69
92
  /** Optional complexity label, stringified for the metric labels. */
70
93
  complexity?: number | string;
@@ -83,23 +106,27 @@ export function recordLlmUsage(
83
106
  opts: RecordUsageOptions
84
107
  ): { tokens: number; cost: number } {
85
108
  if (!rawUsage) return { tokens: 0, cost: 0 };
86
- const { tokens, cost } = extractUsage(model, rawUsage);
109
+ const { tokens, cost, parsed } = extractUsage(model, rawUsage);
87
110
  warnIfUnpriced(model, tokens, cost);
88
111
  if (tokens > 0 || cost > 0) {
89
112
  const labels: Record<string, string> = { component: opts.component };
90
113
  if (opts.complexity !== undefined) labels['complexity'] = String(opts.complexity);
91
114
  metrics.increment('llm_tokens_total', tokens, labels);
92
115
  metrics.increment('llm_cost_total', cost, labels);
116
+ recordCacheTokens(parsed, labels);
93
117
  opts.observer?.onTokensConsumed?.(tokens, cost);
94
118
  // Phase-scoped events: coordinator/evaluator call sites label their usage
95
- // 'coordinator'/'evaluator' (see planning-service.ts); route those to the
119
+ // 'coordinator'/'router'/'synthesizer' (see planning-service.ts); route those to the
96
120
  // dedicated observer hooks so the TUI coord/eval cost rows and the SDK
97
121
  // planning_tokens/evaluation_tokens events fire. Before this mapping the hooks
98
122
  // had zero emit sites (regression in c90d7f37) — coordinator/evaluator usage
99
123
  // reached only onTokensConsumed, which the TUI observer does not implement.
100
124
  if (opts.component === 'coordinator') {
101
125
  opts.observer?.onPlanningTokens?.(tokens, cost);
102
- } else if (opts.component === 'evaluator') {
126
+ } else if (opts.component === 'router' || opts.component === 'synthesizer') {
127
+ // Both halves of the research lead feed the one evaluation row. They used to be a
128
+ // single 'evaluator' component; splitting the roles must not split the cost the
129
+ // user sees, so they are summed here rather than given separate sinks.
103
130
  opts.observer?.onEvaluationTokens?.(tokens, cost);
104
131
  }
105
132
  }
@@ -133,9 +133,19 @@ export function extractRunStats(snapshot: IMetricsSnapshot): ResearchStats | nul
133
133
  // Researchers launched
134
134
  const researchersLaunched = sumCounter(counters, 'researchers_launched_total');
135
135
 
136
- // Search queries — combine browser search and regular search
136
+ // Search queries. These two counters count the SAME queries, one layer apart:
137
+ // browser-search increments `browser_search_queries_total` exactly once per query
138
+ // across its success/no-results/timeout/error branches, and its only caller then
139
+ // increments `search_queries_total` by successes plus failures — the same set. Summing
140
+ // them reported "N searches" at exactly twice the truth in the report footer and the
141
+ // session metrics view, and three times it on the outer-catch path, which adds
142
+ // `queries.length` a second time.
143
+ //
144
+ // Same shape as the URL counters below, and resolved the same way: the per-query
145
+ // counter IS the count; the caller-level one is a fallback for a search that threw
146
+ // before dispatching anything, where the inner counter never fired at all.
137
147
  const browserSearchQueries = sumCounter(counters, 'browser_search_queries_total');
138
- const searchQueries = sumCounter(counters, 'search_queries_total') + browserSearchQueries;
148
+ const searchQueries = browserSearchQueries || sumCounter(counters, 'search_queries_total');
139
149
 
140
150
  // URLs discovered via search. browser_search_unique_urls_total is the
141
151
  // cross-query deduped subset of the SAME batch counted by
@@ -203,8 +213,14 @@ export function extractRunStats(snapshot: IMetricsSnapshot): ResearchStats | nul
203
213
  }
204
214
  }
205
215
 
206
- // Rounds completed — evaluator runs give us the round count
207
- const roundsCompleted = sumCounter(counters, 'evaluator_runs_total');
216
+ // Rounds completed. This read `evaluator_runs_total`, whose only reference in the
217
+ // whole repository was this line — the emit site was deleted in c90d7f37 and the
218
+ // reader was left behind, so the value was always 0 and `buildResearchSummary`'s
219
+ // `roundsCompleted > 1` gate meant the "N rounds" element had never once rendered for
220
+ // any multi-round run. The orchestrator now emits a counter that says what it means.
221
+ // The old name is still summed so a snapshot captured before this change still reads.
222
+ const roundsCompleted =
223
+ sumCounter(counters, 'research_rounds_completed_total') || sumCounter(counters, 'evaluator_runs_total');
208
224
 
209
225
  // Complexity
210
226
  let complexity = 1;
@@ -83,7 +83,7 @@ export class MetricsRegistry {
83
83
  public increment(name: string, value: number = 1, labels?: Labels): void {
84
84
  const key = this.getKey(name, labels);
85
85
  this.counters.set(key, (this.counters.get(key) ?? 0) + value);
86
- if (name === 'llm_cost_total' || name === 'llm_tokens_total') {
86
+ if (name.startsWith('llm_')) {
87
87
  logger.debug(`[Metrics] Incremented ${name} by ${value}${labels ? ` (labels: ${JSON.stringify(labels)})` : ''}`);
88
88
  }
89
89
  }
@@ -243,6 +243,32 @@ class SessionMetrics {
243
243
  return this.getActive().measure(name, action, labels);
244
244
  }
245
245
 
246
+ /**
247
+ * Emit API that ALWAYS targets the session registry, ignoring any active run
248
+ * context.
249
+ *
250
+ * For process-lifetime callbacks that happen to be *registered* during a run.
251
+ * AsyncLocalStorage resolves at emit time from the async resource that fired,
252
+ * and for a listener installed on a long-lived emitter that resource is the
253
+ * emitter itself — created, for every lazily-initialized subsystem here, inside
254
+ * whichever run first needed it. So a worker-pool error handler installed during
255
+ * run 1 keeps writing into run 1's registry for the rest of the process: those
256
+ * counts disappear from every later run's summary (the registry was discarded
257
+ * when the run ended) and the dead registry stays reachable through the closure.
258
+ *
259
+ * Use this only where the emission is genuinely process-scoped. Anything that
260
+ * describes work a run asked for belongs in the run registry, which is what the
261
+ * plain emit methods above give you.
262
+ */
263
+ public readonly session = {
264
+ increment: (name: string, value: number = 1, labels?: Labels): void =>
265
+ this._session.increment(name, value, labels),
266
+ setGauge: (name: string, value: number, labels?: Labels): void =>
267
+ this._session.setGauge(name, value, labels),
268
+ observe: (name: string, value: number, labels?: Labels): void =>
269
+ this._session.observe(name, value, labels),
270
+ };
271
+
246
272
  // ── Session-level read API ─────────────────────────────────────────────
247
273
 
248
274
  /** Snapshot of the session registry (infrastructure / cross-run metrics). */
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { runWorkerSearch } from '../infrastructure/browser/task-execution-service.ts';
8
8
  import { getMaxWorkers } from '../infrastructure/browser/config.ts';
9
+ import { isTaskTimeoutError } from '../infrastructure/browser/browser-error-utils.ts';
9
10
  import { logger } from '../logger.ts';
10
11
  import { safeUnref } from '../utils/safe-unref.ts';
11
12
  import { normalizeUrl } from '../utils/url-utils.ts';
@@ -80,19 +81,20 @@ export async function performSearch(
80
81
  // Hard cap per query so a single Cloudflare block or hung browser worker
81
82
  // cannot stall the entire Promise.all burst.
82
83
  //
83
- // The budget is the worker's nav budget (SEARCH_TIMEOUT_MS) PLUS the
84
- // queue-wait/overhead margin (BROWSER_TASK_TIMEOUT_MS), mirroring the
85
- // scheduler's own task-timeout ceiling in BrowserTaskScheduler.runSearch
86
- // (= SEARCH_TIMEOUT_MS + BROWSER_TASK_TIMEOUT_MS). This clock starts at
87
- // ENQUEUE (before runWorkerSearch queues onto the shared browser pool), so
88
- // a budget of only SEARCH_TIMEOUT_MS would let a query that sat in the
89
- // saturated worker queue burn its ENTIRE nav budget waiting for a slot and
90
- // abort with zero actual search work — exactly the "Query timed out after
91
- // 45000ms (likely blocked or slow startup)" cascade observed under
92
- // concurrent load. Including the margin lets a queued query still use its
93
- // full nav budget once a worker picks it up, and keeps this layer's hard
94
- // cap coherent with the scheduler's so it never preempts the worker's own
95
- // (post-dequeue) deadline.
84
+ // The budget mirrors the scheduler's own task ceiling in
85
+ // BrowserTaskScheduler.runSearch (= SEARCH_TIMEOUT_MS + BROWSER_TASK_TIMEOUT_MS)
86
+ // so this layer never preempts the worker's deadline with a shorter one.
87
+ //
88
+ // Both clocks now start at DISPATCH (see startQueryDeadline below), which is
89
+ // what makes mirroring the value correct. While this one started at enqueue,
90
+ // equal values meant the two raced and whichever fired first reported — which
91
+ // is why the same starvation shows up in the logs under two different messages
92
+ // on different days. The earlier response to that was to widen this budget
93
+ // (nav plus a margin) so a queued query could still use its full nav budget
94
+ // once picked up. That treats the symptom: a margin only buys one extra
95
+ // query's worth of wait, and a burst four times the pool's reach exhausts any
96
+ // constant. Measuring execution instead of waiting is the fix; the sum
97
+ // survives as the budget because a search legitimately costs nav plus overhead.
96
98
  const QUERY_TIMEOUT_MS = (config?.SEARCH_TIMEOUT_MS ?? 45_000) + (config?.BROWSER_TASK_TIMEOUT_MS ?? 10_000);
97
99
 
98
100
  // Deduplicated: resultMap and the failures map are both keyed by the query
@@ -108,11 +110,34 @@ export async function performSearch(
108
110
  // timeout guess that misdirects to the network or system load.
109
111
  let sampleWorkerError = '';
110
112
 
111
- const searchTasks = filteredQueries.map(async (query) => {
113
+ const runQuery = async (query: string): Promise<void> => {
112
114
  const queryStartTime = Date.now();
113
115
  const timeoutController = new AbortController();
114
- const timeoutId = setTimeout(() => timeoutController.abort(), QUERY_TIMEOUT_MS);
115
- safeUnref(timeoutId);
116
+ // Armed by onDispatch below — when the browser queue hands this query to a
117
+ // worker — not here. Arming here charged queue wait to the query's budget, so
118
+ // a burst larger than the pool could serve inside one budget killed its own
119
+ // tail: every query enqueued in the same millisecond, and 90s later the ones
120
+ // still waiting aborted together having done no work. Measured at 78 of 100.
121
+ // The scheduler owns the execution deadline; this is the caller-side mirror of
122
+ // it, and against a follower scheduler — which cannot observe leader dispatch
123
+ // and so never calls onDispatch — it stays disarmed and the client's own
124
+ // bound applies instead.
125
+ let timeoutId: NodeJS.Timeout | undefined;
126
+ const onTaskEvent = (event: 'dispatched' | 'settled') => {
127
+ // Disarm on every event, so the clock only ever runs while a worker is
128
+ // actually holding this query. Arming alone was not enough: retries inside
129
+ // runWorkerSearch dispatch AGAIN, and between a failed attempt and its
130
+ // redispatch there is a backoff sleep, a re-enqueue and a fresh queue wait.
131
+ // A timer left running across that gap aborts the retry for its
132
+ // predecessor's overrun, and reports it as "the browser pool was
133
+ // saturated" — the same charge-the-wait-to-the-work mistake, one attempt
134
+ // removed.
135
+ if (timeoutId) clearTimeout(timeoutId);
136
+ timeoutId = undefined;
137
+ if (event !== 'dispatched') return;
138
+ timeoutId = setTimeout(() => timeoutController.abort(), QUERY_TIMEOUT_MS);
139
+ safeUnref(timeoutId);
140
+ };
116
141
 
117
142
  try {
118
143
  if (signal?.aborted) {
@@ -124,7 +149,7 @@ export async function performSearch(
124
149
  ? AbortSignal.any([signal, timeoutController.signal])
125
150
  : timeoutController.signal;
126
151
 
127
- const results = await runWorkerSearch(query, config, querySignal, 1, sessionId, container);
152
+ const results = await runWorkerSearch(query, config, querySignal, 1, sessionId, container, undefined, onTaskEvent);
128
153
  const queryDuration = Date.now() - queryStartTime;
129
154
  metrics.observe('browser_search_query_duration_ms', queryDuration);
130
155
 
@@ -168,7 +193,21 @@ export async function performSearch(
168
193
  }
169
194
  } catch (error) {
170
195
  const queryDuration = Date.now() - queryStartTime;
171
- const isTimeout = timeoutController.signal.aborted && !signal?.aborted;
196
+ // Two clocks can end this query, and only one of them used to be recognised.
197
+ // The scheduler's own execution deadline is armed in the same synchronous
198
+ // block as this layer's guard, with the SAME duration derived from the same
199
+ // two config values — so on the leader path the scheduler's timer is always
200
+ // registered first, always fires first, and its rejection clears this layer's
201
+ // timer before Node ever reaches it. `timeoutController.signal.aborted` was
202
+ // therefore false for every real search timeout in the leader path.
203
+ //
204
+ // The consequences all pointed the wrong way: the query was logged at ERROR,
205
+ // counted as `status=error`, and reported to the researcher as "an
206
+ // infrastructure failure" — while `timeoutCount` stayed at zero, making the
207
+ // whole-burst timeout attribution below unreachable. A task timeout is a
208
+ // timeout whichever clock names it.
209
+ const isTimeout = (timeoutController.signal.aborted || isTaskTimeoutError(error))
210
+ && !signal?.aborted;
172
211
  const status = isTimeout ? 'timeout' : 'error';
173
212
 
174
213
  if (isTimeout) timeoutCount++;
@@ -211,9 +250,43 @@ export async function performSearch(
211
250
  clearTimeout(timeoutId);
212
251
  if (onProgress) onProgress(seenUrls.size);
213
252
  }
214
- });
253
+ };
215
254
 
216
- await Promise.all(searchTasks);
255
+ // Dispatch through a bounded number of lanes instead of handing every query to
256
+ // Promise.all at once.
257
+ //
258
+ // The old form started ALL N queries simultaneously, which meant N minus maxWorkers of
259
+ // them sat in the shared browser queue with their per-query deadline ALREADY TICKING —
260
+ // the clock started at enqueue, not when a worker picked the query up. That made the
261
+ // number of queries which could possibly succeed `maxWorkers x (budget / latency)`
262
+ // rather than N. Measured on a level-3 run: 100 queries dispatched into 6 workers went
263
+ // 94 deep in the queue, 78 of them aborted at the 90s mark having never run, and the
264
+ // burst returned 49 results. Worse, each of those was reported to its researcher as a
265
+ // timeout with "retrying the same query later is reasonable", so the next round
266
+ // re-queued them and the burst grew.
267
+ //
268
+ // A lane only picks up its next query when the previous one finishes, so the shared
269
+ // queue never builds a backlog and every query eventually runs. The burst takes longer
270
+ // in wall-clock terms; it no longer discards most of itself to get there.
271
+ //
272
+ // Lanes are not what makes a query's deadline honest — dispatch-armed timers do that,
273
+ // at both this layer and the scheduler's, and they hold no matter how deep the queue
274
+ // gets. Lanes remain because bounding depth is worth having on its own: the pool is
275
+ // shared with scrapes and healthchecks and with other concurrent runs, and a burst that
276
+ // dumps its whole plan into the queue delays all of them.
277
+ const lanes = Math.max(1, Math.min(maxWorkers, filteredQueries.length));
278
+ let nextIndex = 0;
279
+ const runLane = async (): Promise<void> => {
280
+ for (;;) {
281
+ const index = nextIndex++;
282
+ if (index >= filteredQueries.length) return;
283
+ // Stop pulling new work once the caller has cancelled; queries already
284
+ // dispatched settle through their own abort path.
285
+ if (signal?.aborted) return;
286
+ await runQuery(filteredQueries[index]!);
287
+ }
288
+ };
289
+ await Promise.all(Array.from({ length: lanes }, () => runLane()));
217
290
 
218
291
  // A cancelled run is not a broken one. Every query aborts with zero results,
219
292
  // which walks straight into the total-failure branch below and reports
@@ -1,118 +0,0 @@
1
- # Lead Evaluator
2
-
3
- You orchestrate the next phase of research.
4
-
5
- ## Your Context
6
- - **ROOT QUERY**: {{root_query}}
7
- - **Current round**: {{round_number}} / {{max_rounds}}
8
- - **Complexity Level**: {{complexity_label}}
9
- {{initial_agenda_section}}
10
- {{previous_queries_section}}
11
- {{additional_considerations}}
12
-
13
- ## CRITICAL: Steering Compliance Checkpoint
14
- - **STRICT STEERING**: If there is an "ADDITIONAL USER GUIDANCE" section above, you MUST treat every point in it as a **mandatory rule and instruction** for your decisions, next round goals, and queries. Do not ignore them. They are directional requirements.
15
- - **STEERING SATISFACTION**: Before deciding to SYNTHESIZE, you MUST explicitly verify that ALL user steering requirements have been satisfied by the existing findings. If ANY steering requirement remains unaddressed or only superficially covered, you MUST DELEGATE — do NOT synthesize. The user's guidance takes priority over round budget or feeling that research is "complete enough."
16
-
17
- {{disabled_tools_section}}
18
-
19
- ---
20
-
21
- ## Complexity-Aware Decision Thresholds
22
-
23
- {{complexity_guidance}}
24
-
25
- {{round_phase_guidance}}
26
-
27
- ---
28
-
29
- ## Decision Framework
30
-
31
- **SYNTHESIZE if:** Research meets the complexity-specific synthesis criteria above.
32
- **DELEGATE if:** Research meets the complexity-specific delegation criteria above.
33
- **Note on Guidance**: If the user provides additional guidance mid-research (via Alt+Enter or direct follow-up), those messages will be provided to you. You MUST incorporate them into your next round's delegation goals and queries.
34
-
35
- **STAY IN SCOPE (every delegation):**
36
- - Going deeper on in-scope material is always welcome. Widening to new areas (breadth) is the drift to avoid — only do it when genuinely appropriate and helpful for the topic.
37
- - A gap is missing depth on what was asked — not a broader topic next to it. Don't add anything the query didn't ask for, even if it came up in earlier findings.
38
- - Match the query's scope-bounding constraints exactly — don't broaden them, don't narrow them.
39
-
40
- **COMMON MISTAKES TO AVOID:**
41
- - Do NOT synthesize early just because you've done "enough" rounds. Synthesis should only occur when you have comprehensive, high-quality findings across all major topics.
42
- - Do NOT synthesize with gaps remaining, hoping they can be "filled in later". Complete the research first.
43
- - Do NOT invent a gap outside the query's scope just to keep going or fill the budget. Finish what was asked; don't add what wasn't.
44
-
45
- Use unique, targeted queries for any new researchers.
46
-
47
- **Decision**: Return valid JSON in a code block:
48
-
49
- **If synthesizing**:
50
- ```json
51
- {
52
- "action": "synthesize",
53
- "content": "<Plain prose report with [N] citations, organized by topic. No markdown headings, bullets, or bold text. Followed by the mandatory CITED LINKS section.>"
54
- }
55
- ```
56
-
57
- **If delegating**:
58
- ```json
59
- {
60
- "action": "delegate",
61
- "researchers": [
62
- {
63
- "id": "{{round_number}}.1",
64
- "name": "<Researcher Specialty>",
65
- "goal": "<Focused gap or new angle to investigate>",
66
- "queries": ["<query_1>", "<query_2>", "<query_3>", "..."]
67
- }
68
- ],
69
- "allQueries": ["<flat_list_of_all_queries_from_all_new_researchers>"]
70
- }
71
- ```
72
-
73
- **DELEGATION REQUIREMENTS**:
74
- - **CRITICAL — Queries are mandatory**: Every researcher MUST have at least one query. Never plan a researcher without queries. Researchers receive ONLY the search results you delegate to them.
75
- - **Maximize queries**: For EACH researcher, generate the maximum number of targeted, specific queries within the budget. Do not plan fewer than needed — fill the budget. Queries should target primary sources and authoritative evidence.
76
- - **YouTube discovery**: For roughly one in {{youtube_query_every_n}} queries, append the word `youtube` to the query text (e.g. `"<topic> analysis youtube"`). DuckDuckGo rarely surfaces YouTube on its own, and YouTube links let researchers read video transcripts. Skip only when the gap is clearly unsuited to video.
77
- - **Temporal anchoring**: Anchor every new time-sensitive query to the current month and year shown at the top of this prompt. Use that exact year (and month where relevant); never default to an older year than the one shown there unless the user explicitly asks about the past.
78
- - **Flexible coverage**: Use up to {{max_team_size}} researchers to cover distinct angles in parallel. Scale based on research gaps — a single well-targeted researcher is often sufficient for focused gaps.
79
- - **Source diversity**: Encourage researchers to find multiple authoritative sources per topic area to enable comprehensive citations in the final synthesis.
80
-
81
- ---
82
-
83
- ## Quality Standards for Delegation
84
-
85
- When delegating, ensure:
86
- - **Query Specificity**: Each new query targets distinct, unexplored territory
87
- - **No Redundancy**: Do not repeat queries from previous rounds
88
- - **Specialized Focus**: Each new researcher has a clear, distinct angle
89
- - **Gap-Driven**: Only delegate when gaps cannot be resolved from existing findings
90
- - **Progressive Depth**: New queries go deeper, or to a new angle still inside the query's scope — never outside it, and never repeating surface-level coverage
91
-
92
- ---
93
-
94
- ## Synthesis Protocol (action = synthesize)
95
-
96
- 1. **Organization**: Organize the report logically **BY TOPIC**. Do NOT structure it by researcher or round.
97
- 2. **Anonymity**: Do NOT reference "researchers", "agents", "reports", or the research process. Present the findings as a direct, unified knowledge base.
98
- 3. **Master Links List**: A **Global Source List** has been provided to you. Use these sequential numbers [1], [2], [3], etc., for all inline citations. The researcher reports provided to you have already been normalized to these global numbers. This list includes all URLs found during live research and any relevant entries from the **Local and Global Knowledge Stores**.
99
- 4. **Exhaustive Synthesis**: Use ALL findings from ALL reports. Include every fact, date, name, and statistic verbatim. Longer is better.
100
- 5. **Strict Grounding**: Every sentence must come from a report. Use [N] inline citations. No prior knowledge.
101
- 6. **CRITICAL — Links at Bottom Only**:
102
- - Write all topic sections first with inline citations [1], [2], etc., using the numbers from the **Global Source List**.
103
- - Place exactly ONE `CITED LINKS` section at the VERY END of the entire synthesis.
104
- - This section must contain the complete master list of all unique URLs exactly as provided in the **Global Source List**.
105
- - Do NOT include any links within topic sections or subsections.
106
- - Format: `[1] https://url.com [Source: ...] — brief description` on each line.
107
-
108
- > **MANDATORY TERMINATION RULE**: Your synthesis is not complete until it ends with `CITED LINKS`. Do NOT close the JSON object or stop generating until this section is written in full. A synthesis without `CITED LINKS` is a failed output.
109
-
110
- ---
111
-
112
- ## Output Requirements
113
-
114
- - **Researcher IDs**: Use Round.Index format (e.g. **{{round_number}}.1**, **{{round_number}}.2**).
115
- - **Query Budget**: Use the complexity-specific budget ({{query_budget}} per researcher). Fill each researcher's query budget completely.
116
- - **Team Size**: Scale researcher count to match the gaps. Use up to {{max_team_size}} researchers when delegating, but a single well-targeted researcher is often sufficient for focused gaps. Don't pad the team when fewer researchers will cover the remaining gaps efficiently.
117
- - **Synthesis Quality**: Logical topic-based structure in plain prose, maximal detail, NO mention of researchers, NO markdown headings or bullets.
118
- - **Format**: ONLY return valid JSON in a code block.