@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.
- package/README.md +2 -2
- package/dist/cli.mjs +5495 -4645
- package/dist/prompts/research-tool-usage.md +1 -1
- package/dist/prompts/researcher.md +1 -0
- package/dist/prompts/system-lead-router.md +122 -0
- package/dist/prompts/system-lead-synthesizer.md +46 -0
- package/dist/thread-worker.mjs +94 -19
- package/docs/ARCHITECTURE.md +18 -10
- package/docs/CHANGELOG.md +149 -0
- package/docs/CONFIGURATION.md +106 -7
- package/package.json +2 -2
- package/scripts/build.cjs +8 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +5 -1
- package/src/config.ts +12 -8
- package/src/constants.ts +1 -1
- package/src/core/interfaces/observer-interfaces.ts +3 -3
- package/src/core/interfaces/orchestration-interfaces.ts +2 -0
- package/src/core/interfaces/planning-interfaces.ts +7 -1
- package/src/core/interfaces/scheduler-interfaces.ts +18 -2
- package/src/core/llm/agentic-repair.ts +7 -2
- package/src/core/llm/inject-date.ts +4 -1
- package/src/core/llm/llm-utils.ts +7 -0
- package/src/core/planning-service.ts +719 -91
- package/src/core/planning-utils.ts +21 -5
- package/src/core/service-initialization.ts +19 -3
- package/src/healthcheck/index.ts +11 -2
- package/src/index.ts +4 -3
- package/src/infrastructure/browser/browser-client.ts +68 -34
- package/src/infrastructure/browser/browser-task-scheduler.ts +220 -34
- package/src/infrastructure/browser/config.ts +41 -1
- package/src/infrastructure/browser/priority-task-queue.ts +55 -1
- package/src/infrastructure/browser/scheduler-factory.ts +12 -1
- package/src/infrastructure/browser/task-execution-service.ts +5 -5
- package/src/infrastructure/browser/thread-worker-browser.ts +32 -2
- package/src/infrastructure/browser/thread-worker-messaging.ts +38 -6
- package/src/infrastructure/browser/thread-worker.mjs +94 -19
- package/src/infrastructure/browser/thread-worker.ts +2 -2
- package/src/infrastructure/browser/worker-pool-manager.ts +127 -12
- package/src/infrastructure/embedding/embedding-client.ts +14 -4
- package/src/infrastructure/embedding/embedding-factory.ts +83 -15
- package/src/infrastructure/file-lock-service.ts +214 -19
- package/src/infrastructure/knowledge-store-service.ts +14 -0
- package/src/infrastructure/research-run-semaphore.ts +1 -1
- package/src/infrastructure/service-initialization.ts +7 -0
- package/src/knowledge/embedder-init.ts +43 -17
- package/src/logger.ts +11 -0
- package/src/orchestration/deep-research-orchestrator.ts +141 -87
- package/src/orchestration/quick-research-orchestrator.ts +11 -2
- package/src/orchestration/research-orchestration-service.ts +8 -0
- package/src/orchestration/research-synthesis-service.ts +53 -1
- package/src/orchestration/researcher-executor.ts +53 -2
- package/src/orchestration/researcher.ts +15 -2
- package/src/prompts/research-tool-usage.md +1 -1
- package/src/prompts/researcher.md +1 -0
- package/src/prompts/system-lead-router.md +122 -0
- package/src/prompts/system-lead-synthesizer.md +46 -0
- package/src/tools/research-knowledge-search.ts +11 -3
- package/src/tools/scrape.ts +6 -0
- package/src/tools/search.ts +7 -0
- package/src/tools/security.ts +27 -9
- package/src/tools/stackexchange.ts +13 -7
- package/src/tools/youtube-transcript.ts +26 -12
- package/src/utils/cancellation.ts +36 -0
- package/src/utils/coverage-digest.ts +204 -0
- package/src/utils/llm-usage.ts +33 -6
- package/src/utils/metrics-summary.ts +20 -4
- package/src/utils/metrics.ts +27 -1
- package/src/web-research/browser-search.ts +93 -20
- package/dist/prompts/system-lead-evaluator.md +0 -118
- 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
|
+
}
|
package/src/utils/llm-usage.ts
CHANGED
|
@@ -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'/'
|
|
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', '
|
|
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'/'
|
|
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 === '
|
|
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
|
|
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')
|
|
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
|
|
207
|
-
|
|
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;
|
package/src/utils/metrics.ts
CHANGED
|
@@ -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
|
|
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
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
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
|
|
113
|
+
const runQuery = async (query: string): Promise<void> => {
|
|
112
114
|
const queryStartTime = Date.now();
|
|
113
115
|
const timeoutController = new AbortController();
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|