@lincoln504/pi-research 1.3.11 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cli.mjs +5558 -4596
- package/dist/prompts/research-tool-usage.md +1 -1
- package/dist/prompts/researcher.md +1 -0
- package/dist/prompts/system-coordinator.md +1 -1
- package/dist/prompts/system-lead-router.md +122 -0
- package/dist/prompts/system-lead-synthesizer.md +46 -0
- package/dist/thread-worker.mjs +123 -30
- package/docs/ARCHITECTURE.md +69 -24
- package/docs/CHANGELOG.md +215 -0
- package/docs/CONFIGURATION.md +108 -8
- package/package.json +19 -11
- package/scripts/build.cjs +8 -1
- package/scripts/cleanup.cjs +26 -5
- package/scripts/setup.cjs +25 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +41 -2
- package/src/config.ts +12 -8
- package/src/constants.ts +1 -1
- package/src/core/interfaces/observer-interfaces.ts +3 -3
- package/src/core/interfaces/orchestration-interfaces.ts +2 -0
- package/src/core/interfaces/planning-interfaces.ts +7 -1
- package/src/core/interfaces/scheduler-interfaces.ts +18 -2
- package/src/core/llm/agentic-repair.ts +7 -2
- package/src/core/llm/inject-date.ts +4 -1
- package/src/core/llm/llm-utils.ts +7 -0
- package/src/core/planning-service.ts +719 -91
- package/src/core/planning-utils.ts +21 -5
- package/src/core/service-initialization.ts +19 -3
- package/src/core/service-registry.ts +19 -3
- package/src/healthcheck/index.ts +11 -2
- package/src/index.ts +4 -3
- package/src/infrastructure/browser/browser-client.ts +68 -34
- package/src/infrastructure/browser/browser-task-scheduler.ts +220 -34
- package/src/infrastructure/browser/config.ts +77 -6
- package/src/infrastructure/browser/priority-task-queue.ts +55 -1
- package/src/infrastructure/browser/scheduler-factory.ts +12 -1
- package/src/infrastructure/browser/task-execution-service.ts +17 -7
- package/src/infrastructure/browser/thread-worker-browser.ts +66 -20
- package/src/infrastructure/browser/thread-worker-messaging.ts +38 -6
- package/src/infrastructure/browser/thread-worker.mjs +123 -30
- package/src/infrastructure/browser/thread-worker.ts +2 -2
- package/src/infrastructure/browser/worker-pool-manager.ts +127 -12
- package/src/infrastructure/embedding/embedding-client.ts +14 -4
- package/src/infrastructure/embedding/embedding-factory.ts +83 -15
- package/src/infrastructure/file-lock-service.ts +214 -19
- package/src/infrastructure/gpu-resource-service.ts +15 -1
- package/src/infrastructure/knowledge-store-service.ts +14 -0
- package/src/infrastructure/research-run-semaphore.ts +1 -1
- package/src/infrastructure/service-initialization.ts +7 -0
- package/src/knowledge/embedder-init.ts +60 -18
- package/src/knowledge/store.ts +11 -5
- package/src/logger.ts +11 -0
- package/src/orchestration/deep-research-orchestrator.ts +141 -87
- package/src/orchestration/quick-research-orchestrator.ts +11 -2
- package/src/orchestration/research-orchestration-service.ts +30 -1
- package/src/orchestration/research-synthesis-service.ts +53 -1
- package/src/orchestration/researcher-executor.ts +53 -2
- package/src/orchestration/researcher.ts +15 -2
- package/src/prompts/research-tool-usage.md +1 -1
- package/src/prompts/researcher.md +1 -0
- package/src/prompts/system-coordinator.md +1 -1
- package/src/prompts/system-lead-router.md +122 -0
- package/src/prompts/system-lead-synthesizer.md +46 -0
- package/src/sdk.ts +10 -1
- package/src/security/nvd-client.ts +55 -4
- package/src/security/osv-client.ts +13 -1
- package/src/tools/research-knowledge-search.ts +29 -4
- package/src/tools/scrape.ts +6 -0
- package/src/tools/search.ts +7 -0
- package/src/tools/security.ts +27 -9
- package/src/tools/stackexchange.ts +13 -7
- package/src/tools/youtube-transcript.ts +26 -12
- package/src/utils/cancellation.ts +36 -0
- package/src/utils/coverage-digest.ts +204 -0
- package/src/utils/llm-usage.ts +33 -6
- package/src/utils/log-utils.ts +16 -1
- package/src/utils/metrics-summary.ts +20 -4
- package/src/utils/metrics.ts +27 -1
- package/src/web-research/browser-search.ts +93 -20
- package/src/web-research/scraper-utils.ts +55 -14
- package/dist/prompts/system-lead-evaluator.md +0 -118
- package/src/prompts/system-lead-evaluator.md +0 -118
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cancellation detection shared by the agent-facing tools.
|
|
3
|
+
*
|
|
4
|
+
* Every tool wrapper is built to never throw: a failure becomes prose the agent
|
|
5
|
+
* reads and acts on. That contract is right for a failure and wrong for a
|
|
6
|
+
* cancellation, because the prose is written to explain a *fault* — "this may be
|
|
7
|
+
* due to API rate limiting", "unable to search security databases, try again
|
|
8
|
+
* later", "may be due to network issues or YouTube bot-protection". When the user
|
|
9
|
+
* presses Ctrl-C, the in-flight work rejects with an abort and those sentences are
|
|
10
|
+
* simply false. The agent believes them: it retries, spends budget the user just
|
|
11
|
+
* asked it to stop spending, and can carry the invented cause into the report.
|
|
12
|
+
*
|
|
13
|
+
* A cancellation must therefore propagate. The run is ending either way, and the
|
|
14
|
+
* throw is what unwinds the agent loop instead of feeding it a fabricated fault.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* True when `error` represents the abort of `signal` (or an abort in general)
|
|
19
|
+
* rather than a genuine failure of the work.
|
|
20
|
+
*
|
|
21
|
+
* The message-shape check is the load-bearing part in practice. Cancellation
|
|
22
|
+
* crosses several layers here — worker IPC, HTTP to the browser leader, the
|
|
23
|
+
* research orchestrator's own abort plumbing — and only some of them preserve
|
|
24
|
+
* `AbortError` as the error name; the rest arrive as a plain Error whose message
|
|
25
|
+
* is the reason string. `signal.aborted` alone is not sufficient either: a tool
|
|
26
|
+
* may be handed no signal at all, and a signal can abort after the failure it is
|
|
27
|
+
* being asked to explain.
|
|
28
|
+
*/
|
|
29
|
+
export function isCancellation(error: unknown, signal?: AbortSignal | null): boolean {
|
|
30
|
+
if (signal?.aborted) return true;
|
|
31
|
+
if (error instanceof Error && error.name === 'AbortError') return true;
|
|
32
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
33
|
+
return /^(aborted|the operation was aborted|research (aborted|cancelled|canceled)|operation cancelled|operation canceled)\.?$/i.test(
|
|
34
|
+
message.trim(),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -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
|
}
|
package/src/utils/log-utils.ts
CHANGED
|
@@ -133,7 +133,22 @@ const KNOWN_TOKEN_PATTERN = new RegExp(
|
|
|
133
133
|
'g',
|
|
134
134
|
);
|
|
135
135
|
// JSON Web Tokens — header.payload.signature, each a base64url segment.
|
|
136
|
-
|
|
136
|
+
//
|
|
137
|
+
// The segment lengths are bounded for the same load-bearing reason the URL
|
|
138
|
+
// scheme above is, and this pattern was missed when that one was fixed. `.` is
|
|
139
|
+
// NOT in the segment class, so on a run of base64url characters containing no
|
|
140
|
+
// dot, `[A-Za-z0-9_-]+` consumes to the end of the run and then backtracks one
|
|
141
|
+
// character at a time looking for one — and `_`/`-` count as non-alphanumeric,
|
|
142
|
+
// so B_LEFT admits a fresh start position every few characters inside that same
|
|
143
|
+
// run. `_eyJ` repeated across the 40_000-character scan window measured 393ms of
|
|
144
|
+
// blocked event loop per log message; bounding the segments brings the identical
|
|
145
|
+
// input to 41ms (~10x) with no loss of coverage. The bounds are far above any
|
|
146
|
+
// real token: a 1024-char header covers even an x5c certificate chain, and 8192
|
|
147
|
+
// covers payloads no bearer credential approaches.
|
|
148
|
+
const JWT_PATTERN = new RegExp(
|
|
149
|
+
`${B_LEFT}eyJ[A-Za-z0-9_-]{1,1024}\\.[A-Za-z0-9_-]{1,8192}\\.[A-Za-z0-9_-]{1,4096}`,
|
|
150
|
+
'g',
|
|
151
|
+
);
|
|
137
152
|
// HTTP Basic credentials: "Basic <base64>".
|
|
138
153
|
const BASIC_AUTH_PATTERN = new RegExp(`${B_LEFT}Basic\\s+[A-Za-z0-9+/]{16,}={0,2}`, 'gi');
|
|
139
154
|
// HTTP Bearer credentials: "Bearer <token>". The token is often opaque — many
|
|
@@ -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
|