@lincoln504/pi-research 1.1.2 → 1.2.0
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 +3 -3
- package/dist/cli.mjs +1287 -540
- package/dist/thread-worker.mjs +30 -11
- package/docs/AGENT-SKILL.md +11 -4
- package/docs/ARCHITECTURE.md +19 -9
- package/docs/CHANGELOG.md +81 -1
- package/docs/CONFIGURATION.md +9 -7
- package/docs/KNOWLEDGE-STORE.md +4 -3
- package/docs/PI-EXTENSION.md +1 -1
- package/docs/SDK.md +55 -0
- package/docs/THIRD-PARTY-NOTICES.md +1 -1
- package/package.json +5 -4
- package/scripts/cleanup.cjs +50 -10
- package/scripts/prepare.cjs +15 -5
- package/scripts/setup.cjs +24 -4
- package/skills/pi-research/README.md +9 -2
- package/skills/pi-research/SKILL.md +6 -3
- package/skills/pi-research/references/configuration.md +6 -2
- package/skills/pi-research/scripts/run.mjs +24 -2
- package/src/cli.ts +149 -21
- package/src/config.ts +56 -15
- package/src/constants.ts +31 -0
- package/src/core/interfaces/knowledge-interfaces.ts +6 -1
- package/src/core/interfaces/state-manager-interfaces.ts +1 -1
- package/src/core/pi-version.ts +136 -0
- package/src/core/service-registry.ts +117 -8
- package/src/index.ts +11 -18
- package/src/infrastructure/browser/browser-client.ts +16 -4
- package/src/infrastructure/browser/browser-error-utils.ts +6 -1
- package/src/infrastructure/browser/browser-server.ts +17 -5
- package/src/infrastructure/browser/browser-task-scheduler.ts +101 -23
- package/src/infrastructure/browser/cleanup-utils.ts +45 -7
- package/src/infrastructure/browser/client-agent.ts +4 -3
- package/src/infrastructure/browser/priority-task-queue.ts +10 -3
- package/src/infrastructure/browser/scheduler-factory.ts +26 -1
- package/src/infrastructure/browser/task-execution-service.ts +92 -28
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
- package/src/infrastructure/browser/thread-worker.mjs +30 -11
- package/src/infrastructure/browser/worker-pool-manager.ts +11 -3
- package/src/infrastructure/embedding/embedding-client.ts +22 -8
- package/src/infrastructure/embedding/embedding-factory.ts +28 -11
- package/src/infrastructure/embedding/embedding-server.ts +65 -4
- package/src/infrastructure/file-lock-service.ts +223 -26
- package/src/infrastructure/process-lifecycle-service.ts +108 -31
- package/src/infrastructure/state/state-backup-manager.ts +5 -7
- package/src/infrastructure/state/state-manager-service.ts +1 -1
- package/src/infrastructure/state/state-manager.ts +19 -20
- package/src/infrastructure/types/state-types.ts +9 -0
- package/src/knowledge/embedder-init.ts +1 -1
- package/src/knowledge/embedder.ts +52 -3
- package/src/knowledge/index.ts +1 -1
- package/src/knowledge/store.ts +35 -9
- package/src/knowledge/webgpu-viability.ts +9 -4
- package/src/knowledge/writer-queue.ts +88 -46
- package/src/orchestration/deep-research-orchestrator.ts +13 -2
- package/src/orchestration/quick-research-orchestrator.ts +2 -2
- package/src/orchestration/research-orchestration-service.ts +2 -4
- package/src/orchestration/research-synthesis-service.ts +33 -11
- package/src/security/cisa-kev.ts +10 -3
- package/src/security/github-advisories.ts +18 -7
- package/src/security/nvd-client.ts +17 -6
- package/src/security/osv-client.ts +14 -4
- package/src/skill-install/skill-installer.ts +135 -9
- package/src/stackexchange/rest-client.ts +6 -2
- package/src/tools/research-knowledge-search.ts +15 -0
- package/src/tools/search.ts +6 -1
- package/src/utils/atomic-replace.ts +101 -0
- package/src/utils/citation-utils.ts +3 -1
- package/src/utils/http-body.ts +176 -0
- package/src/web-research/browser-search.ts +50 -4
- package/src/web-research/search.ts +20 -5
- package/src/web-research/types.ts +17 -4
- package/src/web-research/web-scraper.ts +7 -50
- package/src/youtube/transcript-client.ts +5 -1
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capped response-body reading.
|
|
3
|
+
*
|
|
4
|
+
* `response.json()` and `response.text()` buffer the WHOLE body into the main
|
|
5
|
+
* process before anything can inspect its size — the byte count is only knowable
|
|
6
|
+
* after the memory has already been committed. A hostile or merely broken upstream
|
|
7
|
+
* can therefore drive this process to an OOM kill with a single reply, and because
|
|
8
|
+
* a `Content-Length` header is advisory (absent on chunked encoding, and freely
|
|
9
|
+
* falsifiable) no pre-screen closes that hole. Streaming chunk-by-chunk and
|
|
10
|
+
* aborting the instant the cap is crossed is the only check that actually bounds
|
|
11
|
+
* the allocation.
|
|
12
|
+
*
|
|
13
|
+
* The scraper has read HTML and PDF this way for some time; these helpers exist so
|
|
14
|
+
* the JSON API clients share that one implementation rather than each growing a
|
|
15
|
+
* near-copy of it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Thrown by {@link readBodyCapped} when a streamed body crosses its byte cap. */
|
|
19
|
+
export class BodyTooLargeError extends Error {
|
|
20
|
+
constructor(public readonly bytesRead: number, public readonly maxBytes: number) {
|
|
21
|
+
super(`Response body exceeded ${maxBytes} byte cap`);
|
|
22
|
+
this.name = 'BodyTooLargeError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Default cap for a JSON API response.
|
|
28
|
+
*
|
|
29
|
+
* Every endpoint behind these clients answers in kilobytes for a normal query, so
|
|
30
|
+
* 32MB is orders of magnitude of headroom for legitimate traffic while still
|
|
31
|
+
* bounding a runaway body to something a Node heap absorbs without dying. Bulk
|
|
32
|
+
* endpoints that legitimately return more (a full vulnerability catalogue, a
|
|
33
|
+
* maximum-size CVE page) pass their own larger cap explicitly.
|
|
34
|
+
*/
|
|
35
|
+
export const MAX_JSON_BODY_BYTES = 32 * 1024 * 1024;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Cap for bulk catalogue downloads (CISA KEV's full feed, a 2000-item NVD page).
|
|
39
|
+
* These are legitimately tens of megabytes and grow over time, so they get their
|
|
40
|
+
* own ceiling rather than forcing the shared default up for everyone.
|
|
41
|
+
*/
|
|
42
|
+
export const MAX_BULK_JSON_BODY_BYTES = 96 * 1024 * 1024;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Read a response body with a HARD byte cap, streaming chunk-by-chunk so an
|
|
46
|
+
* over-limit body aborts the moment the cap is crossed.
|
|
47
|
+
*
|
|
48
|
+
* A body-less response (test doubles, some mocks) falls back to `arrayBuffer()`;
|
|
49
|
+
* callers that had a post-read size check keep it as the belt for that path.
|
|
50
|
+
*/
|
|
51
|
+
export async function readBodyCapped(response: Response, maxBytes: number): Promise<Uint8Array> {
|
|
52
|
+
const body = response.body;
|
|
53
|
+
// Also tolerate non-standard doubles that expose a body without getReader.
|
|
54
|
+
if (!body || typeof body.getReader !== 'function') {
|
|
55
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const reader = body.getReader();
|
|
59
|
+
const chunks: Uint8Array[] = [];
|
|
60
|
+
let total = 0;
|
|
61
|
+
try {
|
|
62
|
+
for (;;) {
|
|
63
|
+
const { done, value } = await reader.read();
|
|
64
|
+
if (done) break;
|
|
65
|
+
if (value) {
|
|
66
|
+
total += value.byteLength;
|
|
67
|
+
if (total > maxBytes) throw new BodyTooLargeError(total, maxBytes);
|
|
68
|
+
chunks.push(value);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} finally {
|
|
72
|
+
// Over-cap or downstream throw: release the connection instead of leaving
|
|
73
|
+
// the socket open (and the server still sending) until GC. No-op after a
|
|
74
|
+
// clean done=true read.
|
|
75
|
+
try { await reader.cancel(); } catch { /* already closed */ }
|
|
76
|
+
}
|
|
77
|
+
const out = new Uint8Array(total);
|
|
78
|
+
let offset = 0;
|
|
79
|
+
for (const c of chunks) { out.set(c, offset); offset += c.byteLength; }
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Whether this response can actually be read as a stream — i.e. whether the cap
|
|
85
|
+
* can be enforced at all.
|
|
86
|
+
*
|
|
87
|
+
* A real `Response` from `fetch()` always exposes a `ReadableStream` body except
|
|
88
|
+
* for the null-body statuses (204/205/304), which have nothing to cap. So in
|
|
89
|
+
* production this is true exactly when there are bytes to bound, and the capped
|
|
90
|
+
* path is never quietly skipped. It returns false only for the null-body statuses
|
|
91
|
+
* and for hand-rolled test doubles, which implement whichever of
|
|
92
|
+
* `arrayBuffer()`/`text()`/`json()` they need and no stream at all.
|
|
93
|
+
*/
|
|
94
|
+
function hasStreamBody(response: Response): boolean {
|
|
95
|
+
const body = response.body;
|
|
96
|
+
return !!body && typeof body.getReader === 'function';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Capped replacement for `response.text()`.
|
|
101
|
+
*
|
|
102
|
+
* Decodes UTF-8 non-fatally, matching `Response.text()` — a malformed byte becomes
|
|
103
|
+
* U+FFFD rather than throwing, so swapping this in cannot turn a body that used to
|
|
104
|
+
* parse into an exception.
|
|
105
|
+
*/
|
|
106
|
+
export async function readTextCapped(
|
|
107
|
+
response: Response,
|
|
108
|
+
maxBytes: number = MAX_JSON_BODY_BYTES,
|
|
109
|
+
): Promise<string> {
|
|
110
|
+
if (hasStreamBody(response)) {
|
|
111
|
+
return new TextDecoder('utf-8').decode(await readBodyCapped(response, maxBytes));
|
|
112
|
+
}
|
|
113
|
+
// Double/null-body ladder — see hasStreamBody for why this is unreachable for a
|
|
114
|
+
// real body-bearing response.
|
|
115
|
+
if (typeof response.arrayBuffer === 'function') {
|
|
116
|
+
return new TextDecoder('utf-8').decode(new Uint8Array(await response.arrayBuffer()));
|
|
117
|
+
}
|
|
118
|
+
return response.text();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Capped replacement for `response.json()`.
|
|
123
|
+
*
|
|
124
|
+
* Throws {@link BodyTooLargeError} for an over-cap body and a `SyntaxError` for a
|
|
125
|
+
* malformed one, so callers that already distinguish "malformed JSON" from other
|
|
126
|
+
* failures keep working: the size failure is a distinct, catchable type rather
|
|
127
|
+
* than something that masquerades as a parse error.
|
|
128
|
+
*/
|
|
129
|
+
export async function readJsonCapped<T = unknown>(
|
|
130
|
+
response: Response,
|
|
131
|
+
maxBytes: number = MAX_JSON_BODY_BYTES,
|
|
132
|
+
): Promise<T> {
|
|
133
|
+
if (hasStreamBody(response) || typeof response.arrayBuffer === 'function' || typeof response.text === 'function') {
|
|
134
|
+
return JSON.parse(await readTextCapped(response, maxBytes)) as T;
|
|
135
|
+
}
|
|
136
|
+
// A double that implements json() alone.
|
|
137
|
+
return response.json() as Promise<T>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Accumulator for a Node `IncomingMessage` body that decodes UTF-8 exactly ONCE,
|
|
142
|
+
* at the end.
|
|
143
|
+
*
|
|
144
|
+
* The obvious `body += chunk` is silently lossy. `+=` stringifies each Buffer on
|
|
145
|
+
* its own, so a multi-byte character straddling a chunk boundary is decoded as two
|
|
146
|
+
* independent fragments and both halves become U+FFFD. Chunk boundaries fall
|
|
147
|
+
* wherever the socket happens to split, so the damage scales with payload size and
|
|
148
|
+
* lands only on non-ASCII text — measured at ~15 mangled characters in a 560KB CJK
|
|
149
|
+
* body. Nothing throws: `JSON.parse` still succeeds, so the corruption reaches the
|
|
150
|
+
* report and the knowledge store unannounced.
|
|
151
|
+
*
|
|
152
|
+
* Buffering the chunks and concatenating before a single decode is what makes a
|
|
153
|
+
* split sequence whole again.
|
|
154
|
+
*
|
|
155
|
+
* `byteLength` counts real bytes, which is also the honest unit for a payload cap —
|
|
156
|
+
* the old `body.length` measured UTF-16 code units of an already-corrupted string.
|
|
157
|
+
*/
|
|
158
|
+
export class Utf8Body {
|
|
159
|
+
private readonly chunks: Buffer[] = [];
|
|
160
|
+
private bytes = 0;
|
|
161
|
+
|
|
162
|
+
/** Total bytes accumulated so far. */
|
|
163
|
+
get byteLength(): number {
|
|
164
|
+
return this.bytes;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
push(chunk: Buffer): void {
|
|
168
|
+
this.chunks.push(chunk);
|
|
169
|
+
this.bytes += chunk.byteLength;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Concatenate and decode. Safe to call once the stream has ended. */
|
|
173
|
+
toString(): string {
|
|
174
|
+
return Buffer.concat(this.chunks).toString('utf8');
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -9,7 +9,7 @@ import { getMaxWorkers } from '../infrastructure/browser/config.ts';
|
|
|
9
9
|
import { logger } from '../logger.ts';
|
|
10
10
|
import { safeUnref } from '../utils/safe-unref.ts';
|
|
11
11
|
import { normalizeUrl } from '../utils/url-utils.ts';
|
|
12
|
-
import type { SearchResult } from './types.ts';
|
|
12
|
+
import type { SearchResult, QueryFailure } from './types.ts';
|
|
13
13
|
import type { Config } from '../config.ts';
|
|
14
14
|
import { metrics } from '../utils/metrics.ts';
|
|
15
15
|
import { getServiceContainer } from '../core/service-registry.ts';
|
|
@@ -21,11 +21,20 @@ import type { ServiceContainer } from '../core/service-registry.ts';
|
|
|
21
21
|
* Each worker maintains its own "warm" browser process.
|
|
22
22
|
*/
|
|
23
23
|
export async function performSearch(
|
|
24
|
-
queries: string[],
|
|
24
|
+
queries: string[],
|
|
25
25
|
config?: Config,
|
|
26
26
|
signal?: AbortSignal,
|
|
27
27
|
onProgress?: (links: number) => void,
|
|
28
|
-
container: ServiceContainer = getServiceContainer()
|
|
28
|
+
container: ServiceContainer = getServiceContainer(),
|
|
29
|
+
/**
|
|
30
|
+
* Optional sink for per-query FAILURES (timeout / worker error). Populated as
|
|
31
|
+
* an out-param rather than folded into the return value because the returned
|
|
32
|
+
* map's `query -> results` shape is what every caller consumes; a query absent
|
|
33
|
+
* from this map produced zero results with no failure, i.e. the search really
|
|
34
|
+
* did come back empty. Without this the caller cannot tell those apart and
|
|
35
|
+
* reports every empty query as a too-narrow query.
|
|
36
|
+
*/
|
|
37
|
+
failures?: Map<string, QueryFailure>,
|
|
29
38
|
): Promise<Map<string, SearchResult[]>> {
|
|
30
39
|
const startTime = Date.now();
|
|
31
40
|
const resultMap = new Map<string, SearchResult[]>();
|
|
@@ -78,7 +87,12 @@ export async function performSearch(
|
|
|
78
87
|
// (post-dequeue) deadline.
|
|
79
88
|
const QUERY_TIMEOUT_MS = (config?.SEARCH_TIMEOUT_MS ?? 45_000) + (config?.BROWSER_TASK_TIMEOUT_MS ?? 10_000);
|
|
80
89
|
|
|
81
|
-
|
|
90
|
+
// Deduplicated: resultMap and the failures map are both keyed by the query
|
|
91
|
+
// string, so two tasks for the same string race last-writer-wins — instance A
|
|
92
|
+
// delivering results, then instance B timing out, discarded A's results and
|
|
93
|
+
// marked the query failed. One task per distinct string closes that (and stops
|
|
94
|
+
// paying for the duplicate browser work).
|
|
95
|
+
const filteredQueries = [...new Set(queries.filter(q => q.trim()))];
|
|
82
96
|
let timeoutCount = 0;
|
|
83
97
|
let errorCount = 0;
|
|
84
98
|
// Keep one representative worker error so total-failure surfaces the real
|
|
@@ -146,11 +160,31 @@ export async function performSearch(
|
|
|
146
160
|
|
|
147
161
|
if (isTimeout) {
|
|
148
162
|
logger.warn(`[Search] Query timed out after ${QUERY_TIMEOUT_MS}ms — likely starved waiting for a free browser worker under concurrent load, or a slow/blocked search provider: "${query}"`);
|
|
163
|
+
failures?.set(query, {
|
|
164
|
+
type: 'timeout',
|
|
165
|
+
message: `Search timed out after ${QUERY_TIMEOUT_MS}ms before returning anything. This says nothing about the query — the browser pool was saturated or the search provider was slow/blocked. Retrying the same query later is reasonable.`,
|
|
166
|
+
});
|
|
149
167
|
} else {
|
|
150
168
|
const msg = error instanceof Error ? error.message : String(error);
|
|
151
169
|
if (msg !== 'Aborted') {
|
|
152
170
|
if (!sampleWorkerError) sampleWorkerError = msg;
|
|
153
171
|
logger.error(`[Search] Worker failed for "${query}": ${msg}`);
|
|
172
|
+
failures?.set(query, {
|
|
173
|
+
type: 'service_unavailable',
|
|
174
|
+
message: `The search backend failed for this query (${msg}). This is an infrastructure failure, not a signal about the query itself — do not rewrite the query in response to it.`,
|
|
175
|
+
});
|
|
176
|
+
} else if (!signal?.aborted) {
|
|
177
|
+
// A worker-side 'Aborted' with no caller abort (a pool drain or
|
|
178
|
+
// leader handover cancelling the task) is an infrastructure
|
|
179
|
+
// interruption. Without an entry here the query fell through to
|
|
180
|
+
// the empty_results default — "the query may be too narrow" —
|
|
181
|
+
// the exact misattribution this map exists to prevent. The
|
|
182
|
+
// caller-abort case stays unrecorded: the post-loop signal
|
|
183
|
+
// check reports the cancellation itself.
|
|
184
|
+
failures?.set(query, {
|
|
185
|
+
type: 'service_unavailable',
|
|
186
|
+
message: 'The search task was cancelled on the worker side (pool drain or leader handover) before completing. This says nothing about the query — do not rewrite it; retrying the same query later is reasonable.',
|
|
187
|
+
});
|
|
154
188
|
}
|
|
155
189
|
}
|
|
156
190
|
resultMap.set(query, []);
|
|
@@ -162,6 +196,18 @@ export async function performSearch(
|
|
|
162
196
|
|
|
163
197
|
await Promise.all(searchTasks);
|
|
164
198
|
|
|
199
|
+
// A cancelled run is not a broken one. Every query aborts with zero results,
|
|
200
|
+
// which walks straight into the total-failure branch below and reports
|
|
201
|
+
// "Browser workers may be unavailable, DuckDuckGo is unreachable, or the
|
|
202
|
+
// system is under extreme load" — an infrastructure outage the user never had.
|
|
203
|
+
// They pressed Ctrl-C. Check this BEFORE the outage heuristic, and surface the
|
|
204
|
+
// codebase's standard abort signature so the callers that already special-case
|
|
205
|
+
// it (task-execution-service, the orchestrator, the CLI classifier) see a
|
|
206
|
+
// cancellation rather than a failure.
|
|
207
|
+
if (signal?.aborted) {
|
|
208
|
+
throw new Error('Aborted');
|
|
209
|
+
}
|
|
210
|
+
|
|
165
211
|
// Detect total failure: if every valid query returned empty, the worker pool is likely dead.
|
|
166
212
|
const totalResults = Array.from(resultMap.values()).reduce((sum, r) => sum + r.length, 0);
|
|
167
213
|
if (totalResults === 0 && filteredQueries.length > 0) {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { logger } from '../logger.ts';
|
|
8
8
|
import { performSearch } from './browser-search.ts';
|
|
9
|
-
import type { QueryResultWithError } from './types.ts';
|
|
9
|
+
import type { QueryResultWithError, QueryFailure } from './types.ts';
|
|
10
10
|
import type { Config } from '../config.ts';
|
|
11
11
|
import { metrics } from '../utils/metrics.ts';
|
|
12
12
|
import { getServiceContainer } from '../core/service-registry.ts';
|
|
@@ -39,7 +39,10 @@ export async function search(
|
|
|
39
39
|
const searchStart = Date.now();
|
|
40
40
|
|
|
41
41
|
try {
|
|
42
|
-
|
|
42
|
+
// Per-query failures (timeout / dead worker) come back separately from the
|
|
43
|
+
// results so an empty result set can be attributed correctly below.
|
|
44
|
+
const failures = new Map<string, QueryFailure>();
|
|
45
|
+
const resultMap = await performSearch(queries, config, signal, onProgress, container, failures);
|
|
43
46
|
const searchDuration = Date.now() - searchStart;
|
|
44
47
|
metrics.observe('search_latency_ms', searchDuration);
|
|
45
48
|
|
|
@@ -56,7 +59,12 @@ export async function search(
|
|
|
56
59
|
successfulQueries++;
|
|
57
60
|
metrics.observe('search_results_per_query', qResults.length);
|
|
58
61
|
} else {
|
|
59
|
-
result
|
|
62
|
+
// Only call it an empty result when the search actually completed and
|
|
63
|
+
// returned nothing. A recorded failure (timeout, dead worker, breaker
|
|
64
|
+
// fast-fail) carries its own message — telling the researcher "the query
|
|
65
|
+
// may be too narrow" for an infrastructure fault makes it rewrite a
|
|
66
|
+
// perfectly good query instead of retrying.
|
|
67
|
+
result.error = failures.get(q) ?? {
|
|
60
68
|
type: 'empty_results',
|
|
61
69
|
message: 'Browser-based search returned no relevant results for this query (the query may be too narrow, or the site returned nothing usable).'
|
|
62
70
|
};
|
|
@@ -73,11 +81,18 @@ export async function search(
|
|
|
73
81
|
return results;
|
|
74
82
|
} catch (error) {
|
|
75
83
|
const searchDuration = Date.now() - searchStart;
|
|
84
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
85
|
+
// A cancelled run must propagate as a cancellation. Swallowing it into
|
|
86
|
+
// per-query `unknown` errors would let the orchestrator carry on and
|
|
87
|
+
// synthesize from nothing, then report a failure the user never had. Checked
|
|
88
|
+
// BEFORE the error metrics: a user cancelling runs is not a failing search
|
|
89
|
+
// backend, and counting each Esc as queries.length errors painted one.
|
|
90
|
+
if (signal?.aborted || message === 'Aborted') {
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
76
93
|
metrics.observe('search_latency_ms', searchDuration, { status: 'error' });
|
|
77
94
|
metrics.increment('search_queries_total', queries.length, { status: 'error' });
|
|
78
95
|
metrics.increment('search_errors_total', 1, { error_type: 'search_failed' });
|
|
79
|
-
|
|
80
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
81
96
|
// Re-throw total search failure so the orchestrator can surface a clear error
|
|
82
97
|
// rather than silently producing an empty synthesis with zero links.
|
|
83
98
|
if (message.includes('Search completely failed')) {
|
|
@@ -12,10 +12,23 @@ export type { SearchResult, ScrapeResult };
|
|
|
12
12
|
export interface QueryResultWithError {
|
|
13
13
|
query: string;
|
|
14
14
|
results: SearchResult[];
|
|
15
|
-
error?:
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
error?: QueryFailure;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Why one query yielded nothing.
|
|
20
|
+
*
|
|
21
|
+
* The distinction matters to the *researcher agent*, which reads these messages
|
|
22
|
+
* and decides what to do next: `empty_results` is genuine feedback about the
|
|
23
|
+
* query (rewrite it, broaden it), while a timeout or a dead worker says nothing
|
|
24
|
+
* at all about the query and should prompt a retry instead. Collapsing the two —
|
|
25
|
+
* reporting every zero-result query as "the query may be too narrow" — sent the
|
|
26
|
+
* agent off rewriting perfectly good queries whenever the browser pool was the
|
|
27
|
+
* thing that failed.
|
|
28
|
+
*/
|
|
29
|
+
export interface QueryFailure {
|
|
30
|
+
type: 'empty_results' | 'service_unavailable' | 'timeout' | 'network_error' | 'unknown';
|
|
31
|
+
message: string;
|
|
19
32
|
}
|
|
20
33
|
|
|
21
34
|
// Scraper types
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
import { getRandomUserAgent, extractDomain, validateUrlForSSRF, validateContent, createNativeMarkdownConverter, createJsMarkdownConverter, getSsrfSafeDispatcher, formatErrorWithCause, isBenignScrapeFailure, } from './scraper-utils.ts';
|
|
27
27
|
import { isTransientError, abortableDelay } from './retry-utils.ts';
|
|
28
28
|
import { safeUnref } from '../utils/safe-unref.ts';
|
|
29
|
+
import { readBodyCapped, BodyTooLargeError } from '../utils/http-body.ts';
|
|
29
30
|
import { getServiceContainer } from '../core/service-registry.ts';
|
|
30
31
|
import type { ServiceContainer } from '../core/service-registry.ts';
|
|
31
32
|
import { cacheScrapedContent } from '../utils/shared-links.ts';
|
|
@@ -144,56 +145,12 @@ function decodeHtmlBody(bytes: Uint8Array, contentTypeHeader: string): string {
|
|
|
144
145
|
}
|
|
145
146
|
}
|
|
146
147
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Read a response body with a HARD byte cap, streaming chunk-by-chunk so an
|
|
157
|
-
* over-limit body aborts the moment the cap is crossed. The Content-Length
|
|
158
|
-
* pre-screen above is advisory only: a chunked (or lying) response carries no
|
|
159
|
-
* usable length header, and `response.arrayBuffer()` would otherwise buffer the
|
|
160
|
-
* entire body — potentially hundreds of MB from a hostile server — into the
|
|
161
|
-
* main process before any size check ran.
|
|
162
|
-
*
|
|
163
|
-
* A body-less response (test doubles, some mocks) falls back to arrayBuffer();
|
|
164
|
-
* callers keep their post-read size checks as the belt for that path.
|
|
165
|
-
*/
|
|
166
|
-
async function readBodyCapped(response: Response, maxBytes: number): Promise<Uint8Array> {
|
|
167
|
-
const body = response.body;
|
|
168
|
-
// Also tolerate non-standard doubles that expose a body without getReader.
|
|
169
|
-
if (!body || typeof body.getReader !== 'function') {
|
|
170
|
-
return new Uint8Array(await response.arrayBuffer());
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const reader = body.getReader();
|
|
174
|
-
const chunks: Uint8Array[] = [];
|
|
175
|
-
let total = 0;
|
|
176
|
-
try {
|
|
177
|
-
for (;;) {
|
|
178
|
-
const { done, value } = await reader.read();
|
|
179
|
-
if (done) break;
|
|
180
|
-
if (value) {
|
|
181
|
-
total += value.byteLength;
|
|
182
|
-
if (total > maxBytes) throw new BodyTooLargeError(total, maxBytes);
|
|
183
|
-
chunks.push(value);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
} finally {
|
|
187
|
-
// Over-cap or downstream throw: release the connection instead of leaving
|
|
188
|
-
// the socket open (and the server still sending) until GC. No-op after a
|
|
189
|
-
// clean done=true read.
|
|
190
|
-
try { await reader.cancel(); } catch { /* already closed */ }
|
|
191
|
-
}
|
|
192
|
-
const out = new Uint8Array(total);
|
|
193
|
-
let offset = 0;
|
|
194
|
-
for (const c of chunks) { out.set(c, offset); offset += c.byteLength; }
|
|
195
|
-
return out;
|
|
196
|
-
}
|
|
148
|
+
// readBodyCapped / BodyTooLargeError live in utils/http-body.ts — the scraper
|
|
149
|
+
// pioneered the streaming cap, and the JSON API clients now share that one
|
|
150
|
+
// implementation instead of each carrying a near-copy. The Content-Length
|
|
151
|
+
// pre-screen above stays advisory only: chunked responses carry no usable length
|
|
152
|
+
// header and the header is freely falsifiable, so the streamed cap is the check
|
|
153
|
+
// that actually bounds the allocation.
|
|
197
154
|
|
|
198
155
|
/**
|
|
199
156
|
* Record a fetch-layer failure exactly once per URL.
|
|
@@ -21,6 +21,7 @@ import { withTimeout, retryWithBackoff, createTimeoutSignal } from '../web-resea
|
|
|
21
21
|
import { CircuitBreaker } from '../utils/circuit-breaker.ts';
|
|
22
22
|
import { logger } from '../logger.ts';
|
|
23
23
|
import { metrics } from '../utils/metrics.ts';
|
|
24
|
+
import { readTextCapped } from '../utils/http-body.ts';
|
|
24
25
|
import { mintPoTokens } from './potoken.ts';
|
|
25
26
|
import { watchUrl } from './video-id.ts';
|
|
26
27
|
|
|
@@ -255,7 +256,10 @@ async function fetchOne(
|
|
|
255
256
|
void response.body?.cancel()?.catch(() => { /* best-effort */ });
|
|
256
257
|
throw new Error(`HTTP ${response.status} from timedtext endpoint`);
|
|
257
258
|
}
|
|
258
|
-
|
|
259
|
+
// A timedtext document is kilobytes; the cap only bounds a hostile or
|
|
260
|
+
// malfunctioning response, which this path is directly exposed to because
|
|
261
|
+
// base_url comes out of a parsed getInfo() payload.
|
|
262
|
+
const body = await readTextCapped(response);
|
|
259
263
|
const text = parseJson3(body);
|
|
260
264
|
|
|
261
265
|
// Empty-body guard: HTTP 200 + empty body == silent PoToken failure.
|