@open-mercato/web-research 0.7.1-develop.7194.1.ab4fc81f82

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/AGENTS.md +67 -0
  3. package/build.mjs +10 -0
  4. package/dist/contract/adapter.js +13 -0
  5. package/dist/contract/adapter.js.map +7 -0
  6. package/dist/contract/errors.js +28 -0
  7. package/dist/contract/errors.js.map +7 -0
  8. package/dist/contract/http.js +7 -0
  9. package/dist/contract/http.js.map +7 -0
  10. package/dist/contract/index.js +9 -0
  11. package/dist/contract/index.js.map +7 -0
  12. package/dist/contract/outcomes.js +58 -0
  13. package/dist/contract/outcomes.js.map +7 -0
  14. package/dist/contract/policy.js +91 -0
  15. package/dist/contract/policy.js.map +7 -0
  16. package/dist/contract/results.js +13 -0
  17. package/dist/contract/results.js.map +7 -0
  18. package/dist/contract/steps.js +1 -0
  19. package/dist/contract/steps.js.map +7 -0
  20. package/dist/contract/version.js +11 -0
  21. package/dist/contract/version.js.map +7 -0
  22. package/dist/engine/cache.js +29 -0
  23. package/dist/engine/cache.js.map +7 -0
  24. package/dist/engine/deadline.js +54 -0
  25. package/dist/engine/deadline.js.map +7 -0
  26. package/dist/engine/engine.js +481 -0
  27. package/dist/engine/engine.js.map +7 -0
  28. package/dist/engine/index.js +20 -0
  29. package/dist/engine/index.js.map +7 -0
  30. package/dist/engine/runAdapter.js +57 -0
  31. package/dist/engine/runAdapter.js.map +7 -0
  32. package/dist/engine/types.js +1 -0
  33. package/dist/engine/types.js.map +7 -0
  34. package/dist/extract/classify.js +51 -0
  35. package/dist/extract/classify.js.map +7 -0
  36. package/dist/extract/content.js +104 -0
  37. package/dist/extract/content.js.map +7 -0
  38. package/dist/extract/entities.js +95 -0
  39. package/dist/extract/entities.js.map +7 -0
  40. package/dist/extract/index.js +24 -0
  41. package/dist/extract/index.js.map +7 -0
  42. package/dist/extract/text.js +114 -0
  43. package/dist/extract/text.js.map +7 -0
  44. package/dist/extract/tokenizer.js +141 -0
  45. package/dist/extract/tokenizer.js.map +7 -0
  46. package/dist/fusion/canonicalize.js +88 -0
  47. package/dist/fusion/canonicalize.js.map +7 -0
  48. package/dist/fusion/fuse.js +88 -0
  49. package/dist/fusion/fuse.js.map +7 -0
  50. package/dist/fusion/index.js +11 -0
  51. package/dist/fusion/index.js.map +7 -0
  52. package/dist/index.js +21 -0
  53. package/dist/index.js.map +7 -0
  54. package/dist/loader/describe.js +92 -0
  55. package/dist/loader/describe.js.map +7 -0
  56. package/dist/loader/index.js +31 -0
  57. package/dist/loader/index.js.map +7 -0
  58. package/dist/loader/manifest.js +32 -0
  59. package/dist/loader/manifest.js.map +7 -0
  60. package/dist/loader/registry.js +98 -0
  61. package/dist/loader/registry.js.map +7 -0
  62. package/dist/net/client.js +298 -0
  63. package/dist/net/client.js.map +7 -0
  64. package/dist/net/ssrf.js +165 -0
  65. package/dist/net/ssrf.js.map +7 -0
  66. package/dist/net/url.js +9 -0
  67. package/dist/net/url.js.map +7 -0
  68. package/dist/testing/contract.js +118 -0
  69. package/dist/testing/contract.js.map +7 -0
  70. package/dist/testing/fakeAdapter.js +44 -0
  71. package/dist/testing/fakeAdapter.js.map +7 -0
  72. package/dist/testing/index.js +15 -0
  73. package/dist/testing/index.js.map +7 -0
  74. package/jest.config.cjs +24 -0
  75. package/package.json +65 -0
  76. package/src/contract/adapter.ts +100 -0
  77. package/src/contract/errors.ts +48 -0
  78. package/src/contract/http.ts +46 -0
  79. package/src/contract/index.ts +8 -0
  80. package/src/contract/outcomes.ts +91 -0
  81. package/src/contract/policy.ts +157 -0
  82. package/src/contract/results.ts +79 -0
  83. package/src/contract/steps.ts +45 -0
  84. package/src/contract/version.ts +25 -0
  85. package/src/engine/__tests__/engine.test.ts +597 -0
  86. package/src/engine/cache.ts +46 -0
  87. package/src/engine/deadline.ts +65 -0
  88. package/src/engine/engine.ts +633 -0
  89. package/src/engine/index.ts +22 -0
  90. package/src/engine/runAdapter.ts +89 -0
  91. package/src/engine/types.ts +137 -0
  92. package/src/extract/__tests__/content.test.ts +81 -0
  93. package/src/extract/__tests__/text.test.ts +61 -0
  94. package/src/extract/__tests__/tokenizer.test.ts +85 -0
  95. package/src/extract/classify.ts +81 -0
  96. package/src/extract/content.ts +136 -0
  97. package/src/extract/entities.ts +98 -0
  98. package/src/extract/index.ts +11 -0
  99. package/src/extract/text.ts +124 -0
  100. package/src/extract/tokenizer.ts +197 -0
  101. package/src/fusion/__tests__/fusion.test.ts +198 -0
  102. package/src/fusion/canonicalize.ts +119 -0
  103. package/src/fusion/fuse.ts +145 -0
  104. package/src/fusion/index.ts +8 -0
  105. package/src/index.ts +15 -0
  106. package/src/loader/__tests__/describe.test.ts +119 -0
  107. package/src/loader/__tests__/registry.test.ts +134 -0
  108. package/src/loader/describe.ts +145 -0
  109. package/src/loader/index.ts +25 -0
  110. package/src/loader/manifest.ts +44 -0
  111. package/src/loader/registry.ts +156 -0
  112. package/src/net/__tests__/client.test.ts +273 -0
  113. package/src/net/__tests__/ssrf.test.ts +165 -0
  114. package/src/net/client.ts +434 -0
  115. package/src/net/ssrf.ts +235 -0
  116. package/src/net/url.ts +13 -0
  117. package/src/testing/contract.ts +172 -0
  118. package/src/testing/fakeAdapter.ts +69 -0
  119. package/src/testing/index.ts +9 -0
  120. package/tsconfig.json +10 -0
  121. package/watch.mjs +7 -0
@@ -0,0 +1,65 @@
1
+ export const DEADLINE_REACHED = Symbol('web-research/deadline')
2
+
3
+ export type DeadlineSignal = typeof DEADLINE_REACHED
4
+
5
+ export type Cancellable<T> = {
6
+ readonly promise: Promise<T>
7
+ cancel(): void
8
+ }
9
+
10
+ /** A race participant that resolves once `ms` elapses and can be torn down. */
11
+ export function deadlineRace(ms: number): Cancellable<DeadlineSignal> {
12
+ let cancel = (): void => {}
13
+ const promise = new Promise<DeadlineSignal>((resolve) => {
14
+ const timer = setTimeout(() => resolve(DEADLINE_REACHED), Math.max(0, ms))
15
+ timer.unref?.()
16
+ cancel = () => {
17
+ clearTimeout(timer)
18
+ }
19
+ })
20
+ return { promise, cancel }
21
+ }
22
+
23
+ export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
24
+ if (ms <= 0) return Promise.resolve()
25
+ return new Promise((resolve) => {
26
+ const timer = setTimeout(finish, ms)
27
+ function finish(): void {
28
+ clearTimeout(timer)
29
+ signal?.removeEventListener('abort', finish)
30
+ resolve()
31
+ }
32
+ signal?.addEventListener('abort', finish, { once: true })
33
+ })
34
+ }
35
+
36
+ /**
37
+ * Aborts when any input aborts. Node's `AbortSignal.any` exists on modern
38
+ * runtimes but is wrapped here so teardown is explicit and listeners are always
39
+ * removed — an engine run creates one of these per adapter per request.
40
+ */
41
+ export function linkSignals(signals: ReadonlyArray<AbortSignal | undefined>): {
42
+ readonly signal: AbortSignal
43
+ dispose(): void
44
+ } {
45
+ const controller = new AbortController()
46
+ const active = signals.filter((candidate): candidate is AbortSignal => candidate !== undefined)
47
+ const abort = (): void => {
48
+ controller.abort()
49
+ }
50
+ for (const signal of active) {
51
+ if (signal.aborted) {
52
+ controller.abort()
53
+ break
54
+ }
55
+ }
56
+ if (!controller.signal.aborted) {
57
+ for (const signal of active) signal.addEventListener('abort', abort, { once: true })
58
+ }
59
+ return {
60
+ signal: controller.signal,
61
+ dispose: () => {
62
+ for (const signal of active) signal.removeEventListener('abort', abort)
63
+ },
64
+ }
65
+ }
@@ -0,0 +1,633 @@
1
+ import type { ProbeCost, SearchAdapter } from '../contract/adapter'
2
+ import { silentLogger } from '../contract/http'
3
+ import { timedOut, toOutcomeFailure, type FetchOutcome } from '../contract/outcomes'
4
+ import type { SearchPolicy } from '../contract/policy'
5
+ import {
6
+ searchRequestSchema,
7
+ type FetchRequest,
8
+ type FetchedPage,
9
+ type PageVerdict,
10
+ type SearchRequest,
11
+ type SearchRequestInput,
12
+ type SearchResult,
13
+ } from '../contract/results'
14
+ import type { SearchStep, SearchStepEvent, SearchStepMetrics, StepSink } from '../contract/steps'
15
+ import { classifyPage } from '../extract/classify'
16
+ import { extractMainContent } from '../extract/content'
17
+ import { extractTitle, htmlToText } from '../extract/text'
18
+ import { fuseResults, type FusionInput } from '../fusion/fuse'
19
+ import { buildCacheKey, createSingleFlight } from './cache'
20
+ import { DEADLINE_REACHED, deadlineRace, linkSignals } from './deadline'
21
+ import { outcomeReason, outcomeToStatus, runAdapter, type AdapterRunResult } from './runAdapter'
22
+ import type {
23
+ AdapterDiagnostic,
24
+ AdapterDiagnosticStatus,
25
+ AdapterHealthReport,
26
+ EngineAdapterEntry,
27
+ HealthOptions,
28
+ RunOptions,
29
+ SearchEngine,
30
+ SearchEngineOptions,
31
+ SearchEngineResult,
32
+ } from './types'
33
+
34
+ const TEXTUAL_CONTENT = ['text/', 'application/xhtml+xml', 'application/json', 'application/xml']
35
+ const HEALTH_TIMEOUT_MS = 5_000
36
+
37
+ const PROBE_COST_RANK: Record<ProbeCost, number> = { free: 0, heavy: 1, billable: 2 }
38
+
39
+ /**
40
+ * An adapter that does not declare `probeCost` is assumed to bill when it is a
41
+ * metered source. Pessimistic on purpose: the cost of guessing wrong downwards
42
+ * is a surprise invoice, and the cost of guessing wrong upwards is one row that
43
+ * says "not verified" until somebody clicks.
44
+ */
45
+ function resolveProbeCost(adapter: SearchAdapter): ProbeCost {
46
+ return adapter.probeCost ?? (adapter.capabilities.cost === 'metered' ? 'billable' : 'free')
47
+ }
48
+
49
+ function statusToEvent(status: AdapterDiagnosticStatus): SearchStepEvent {
50
+ return status === 'skipped' ? 'unavailable' : status
51
+ }
52
+
53
+ function normalizeRequest(
54
+ input: SearchRequestInput,
55
+ policy: SearchPolicy,
56
+ ): { request: SearchRequest; includeContent: boolean } {
57
+ const parsed = searchRequestSchema.parse(input)
58
+ return {
59
+ request: {
60
+ query: parsed.query,
61
+ limit: parsed.limit ?? 10,
62
+ ...(parsed.locale ? { locale: parsed.locale } : {}),
63
+ ...(parsed.freshness ? { freshness: parsed.freshness } : {}),
64
+ ...(parsed.site ? { site: parsed.site } : {}),
65
+ },
66
+ includeContent: parsed.includeContent ?? policy.content.enabledByDefault,
67
+ }
68
+ }
69
+
70
+ export function createSearchEngine(options: SearchEngineOptions): SearchEngine {
71
+ const { policy, http } = options
72
+ const logger = options.logger ?? silentLogger
73
+ const now = options.now ?? Date.now
74
+ const singleFlight = createSingleFlight<SearchEngineResult>()
75
+
76
+ const ordered = [...options.adapters].sort((left, right) => left.order - right.order)
77
+ const browserEntries = ordered.filter((entry) => entry.adapter.kind === 'browser')
78
+
79
+ function createReporter(sinks: ReadonlyArray<StepSink | undefined>) {
80
+ const active = sinks.filter((sink): sink is StepSink => sink !== undefined)
81
+ let seq = 0
82
+ return (
83
+ phase: SearchStep['phase'],
84
+ event: SearchStepEvent,
85
+ adapterId?: string,
86
+ detail?: string,
87
+ metrics?: SearchStepMetrics,
88
+ ): void => {
89
+ if (active.length === 0) return
90
+ const step: SearchStep = {
91
+ seq: seq++,
92
+ at: new Date(now()).toISOString(),
93
+ phase,
94
+ event,
95
+ ...(adapterId ? { adapterId } : {}),
96
+ ...(detail ? { detail } : {}),
97
+ ...(metrics ? { metrics } : {}),
98
+ }
99
+ for (const sink of active) {
100
+ try {
101
+ sink(step)
102
+ } catch (error) {
103
+ logger.warn('[web-research] step sink threw', { error: String(error) })
104
+ }
105
+ }
106
+ }
107
+ }
108
+
109
+ async function enrichContent(
110
+ results: readonly SearchResult[],
111
+ signal: AbortSignal,
112
+ deadlineAt: number,
113
+ report: (event: SearchStepEvent, detail?: string, metrics?: SearchStepMetrics) => void,
114
+ ): Promise<{ results: readonly SearchResult[]; pagesRead: number }> {
115
+ const targets = results.filter((result) => result.content === null).slice(0, policy.content.maxPages)
116
+ if (targets.length === 0) return { results, pagesRead: 0 }
117
+ report('started', `reading ${targets.length} page(s) for inline content`)
118
+
119
+ const contentByUrl = new Map<string, string>()
120
+ // Counts pages we actually went to the network for, not pages that yielded
121
+ // usable text: the budget this feeds is about egress, and a fetch that came
122
+ // back empty still cost a request to somebody else's server.
123
+ let pagesRead = 0
124
+ const queue = [...targets]
125
+ // Bounded by the same knob as the search wave: reading N pages at once is the
126
+ // same egress burst as running N adapters, and it lands on third-party hosts.
127
+ const workers = Array.from({ length: Math.min(policy.concurrency, queue.length) }, async () => {
128
+ for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
129
+ // The run deadline binds here too — otherwise a search bounded to
130
+ // `hardDeadlineMs` could spend minutes reading pages after fusion.
131
+ if (signal.aborted || now() >= deadlineAt) return
132
+ pagesRead += 1
133
+ const outcome = await fetchPage(
134
+ { url: next.url, maxBytes: policy.content.maxBytesPerPage },
135
+ { signal },
136
+ )
137
+ if (outcome.status === 'ok' && outcome.page.text.trim().length > 0) {
138
+ contentByUrl.set(next.url, outcome.page.text)
139
+ }
140
+ }
141
+ })
142
+ await Promise.all(workers)
143
+
144
+ report('ok', `read ${contentByUrl.size} of ${targets.length} page(s)`, {
145
+ resultCount: contentByUrl.size,
146
+ })
147
+
148
+ return {
149
+ results: results.map((result) => {
150
+ const content = contentByUrl.get(result.url)
151
+ return content === undefined ? result : { ...result, content }
152
+ }),
153
+ pagesRead,
154
+ }
155
+ }
156
+
157
+ async function fetchPage(request: FetchRequest, runOptions: RunOptions = {}): Promise<FetchOutcome> {
158
+ const maxBytes = request.maxBytes ?? policy.content.maxBytesPerPage
159
+ const render = request.render ?? 'auto'
160
+
161
+ if (render !== 'always') {
162
+ try {
163
+ const response = await http.request(request.url, {
164
+ maxBytes,
165
+ accept: TEXTUAL_CONTENT,
166
+ ...(runOptions.signal ? { signal: runOptions.signal } : {}),
167
+ })
168
+ const isHtml = response.contentType === null || response.contentType.includes('html')
169
+ const text = isHtml ? extractMainContent(response.body) || htmlToText(response.body) : response.body
170
+ const classification = classifyPage({
171
+ status: response.status,
172
+ contentType: response.contentType,
173
+ html: response.body,
174
+ text,
175
+ })
176
+ const page: FetchedPage = {
177
+ url: response.url,
178
+ title: isHtml ? extractTitle(response.body) : null,
179
+ text,
180
+ contentType: response.contentType,
181
+ status: response.status,
182
+ truncated: response.truncated,
183
+ renderedWith: 'http',
184
+ }
185
+ if (classification.verdict === 'ok' || render === 'never') return { status: 'ok', page }
186
+
187
+ // Escalation is best-effort: a browser failure must not lose the text we
188
+ // already have, so an unusable render falls back to the annotated HTTP page.
189
+ const escalated = await escalateFetch(request, classification.verdict, maxBytes, runOptions)
190
+ if (escalated?.status === 'ok') return escalated
191
+ return { status: 'ok', page: { ...page, escalatedBecause: classification.verdict } }
192
+ } catch (error) {
193
+ const failure = toOutcomeFailure(error)
194
+ if (render !== 'never' && failure.status === 'blocked') {
195
+ const escalated = await escalateFetch(request, 'blocked', maxBytes, runOptions)
196
+ if (escalated?.status === 'ok') return escalated
197
+ }
198
+ return failure
199
+ }
200
+ }
201
+
202
+ const rendered = await escalateFetch(request, 'ok', maxBytes, runOptions)
203
+ return rendered ?? { status: 'unavailable', reason: 'no browser adapter is configured for rendering' }
204
+ }
205
+
206
+ /**
207
+ * Races a fetch against a hard wall-clock budget so an adapter that ignores its
208
+ * abort signal — a wedged headless-browser navigation is the classic case —
209
+ * can never hang the caller. When the timer fires it both aborts the signal (so
210
+ * a cooperative adapter cancels) and wins the race, returning a `timeout`
211
+ * outcome; the leaked adapter promise keeps running in the background without
212
+ * blocking anyone. This is the abort+deadline the search scheduler already
213
+ * gives the adapter wave (`runAdapter` + `deadlineRace`); the fetch/escalation
214
+ * path previously passed only a cooperative signal and awaited directly, so a
215
+ * misbehaving browser render could pin the run for the full parent deadline.
216
+ */
217
+ async function withFetchDeadline(
218
+ budgetMs: number,
219
+ parentSignal: AbortSignal | undefined,
220
+ run: (signal: AbortSignal) => Promise<FetchOutcome>,
221
+ ): Promise<FetchOutcome> {
222
+ const budget = Math.max(0, budgetMs)
223
+ const controller = new AbortController()
224
+ const linked = linkSignals(parentSignal ? [parentSignal, controller.signal] : [controller.signal])
225
+ let timer: ReturnType<typeof setTimeout> | undefined
226
+ const timeout = new Promise<{ readonly kind: 'deadline' }>((resolve) => {
227
+ timer = setTimeout(() => {
228
+ controller.abort()
229
+ resolve({ kind: 'deadline' })
230
+ }, budget)
231
+ timer.unref?.()
232
+ })
233
+ try {
234
+ const raced = await Promise.race([
235
+ run(linked.signal)
236
+ .then((outcome) => ({ kind: 'outcome' as const, outcome }))
237
+ .catch((error) => ({ kind: 'outcome' as const, outcome: toOutcomeFailure(error) })),
238
+ timeout,
239
+ ])
240
+ return raced.kind === 'deadline' ? timedOut(`fetch exceeded ${budget}ms`) : raced.outcome
241
+ } finally {
242
+ if (timer) clearTimeout(timer)
243
+ linked.dispose()
244
+ }
245
+ }
246
+
247
+ /** Re-reads a page through a browser adapter when the HTTP read was insufficient. */
248
+ async function escalateFetch(
249
+ request: FetchRequest,
250
+ because: PageVerdict,
251
+ maxBytes: number,
252
+ runOptions: RunOptions,
253
+ ): Promise<FetchOutcome | null> {
254
+ if (!policy.escalateToBrowser) return null
255
+ const entry = browserEntries.find(
256
+ (candidate) =>
257
+ candidate.enabled && candidate.adapter.fetch !== undefined && candidate.adapter.readiness().ready,
258
+ )
259
+ if (!entry?.adapter.fetch) return null
260
+ const browserFetch = entry.adapter.fetch
261
+
262
+ return withFetchDeadline(policy.hardDeadlineMs, runOptions.signal, async (signal) => {
263
+ const outcome = await browserFetch(
264
+ { url: request.url, maxBytes, render: 'always' },
265
+ { signal, http, report: () => {}, deadlineAt: now() + policy.hardDeadlineMs, logger },
266
+ )
267
+ if (outcome.status !== 'ok') return outcome
268
+ return { status: 'ok', page: { ...outcome.page, renderedWith: 'browser', escalatedBecause: because } }
269
+ })
270
+ }
271
+
272
+ async function runSearch(
273
+ request: SearchRequest,
274
+ includeContent: boolean,
275
+ runOptions: RunOptions,
276
+ report: ReturnType<typeof createReporter>,
277
+ ): Promise<SearchEngineResult> {
278
+ const startedAt = now()
279
+ const deadlineAt = startedAt + policy.hardDeadlineMs
280
+ const softDeadlineAt = startedAt + policy.softDeadlineMs
281
+
282
+ const diagnostics: AdapterDiagnostic[] = []
283
+ const collected: FusionInput[] = []
284
+ const answersById = new Map<string, string>()
285
+ const attempted = new Set<string>()
286
+ const blockedAdapters: string[] = []
287
+ let successCount = 0
288
+ let totalResults = 0
289
+ let answer: string | null = null
290
+
291
+ const searchable: EngineAdapterEntry[] = []
292
+ for (const entry of ordered) {
293
+ if (entry.adapter.kind === 'browser') continue
294
+ if (!entry.enabled) continue
295
+ if (!entry.adapter.capabilities.search) continue
296
+ const readiness = entry.adapter.readiness()
297
+ if (!readiness.ready) {
298
+ diagnostics.push({
299
+ id: entry.adapter.id,
300
+ status: 'skipped',
301
+ latencyMs: 0,
302
+ resultCount: 0,
303
+ reason: readiness.reason,
304
+ })
305
+ report('plan', 'unavailable', entry.adapter.id, readiness.reason)
306
+ continue
307
+ }
308
+ searchable.push(entry)
309
+ report('plan', 'queued', entry.adapter.id)
310
+ }
311
+
312
+ const waveController = new AbortController()
313
+ const wave = linkSignals([runOptions.signal, waveController.signal])
314
+ const adapterDeps = {
315
+ http,
316
+ logger,
317
+ now,
318
+ report: (adapterId: string, event: SearchStepEvent, detail?: string, metrics?: SearchStepMetrics) =>
319
+ report('adapter', event, adapterId, detail, metrics),
320
+ parentSignal: wave.signal,
321
+ deadlineAt,
322
+ adapterTimeoutMs: policy.adapterTimeoutMs,
323
+ }
324
+
325
+ const queue = [...searchable]
326
+ const inflight = new Map<string, Promise<AdapterRunResult>>()
327
+
328
+ const absorb = (settled: AdapterRunResult): void => {
329
+ const id = settled.entry.adapter.id
330
+ inflight.delete(id)
331
+ const status = outcomeToStatus(settled.outcome)
332
+ const resultCount = settled.outcome.status === 'ok' ? settled.outcome.results.length : 0
333
+ const reason = outcomeReason(settled.outcome)
334
+ diagnostics.push({
335
+ id,
336
+ status,
337
+ latencyMs: settled.latencyMs,
338
+ resultCount,
339
+ ...(reason ? { reason } : {}),
340
+ })
341
+ report('adapter', statusToEvent(status), id, reason, {
342
+ latencyMs: settled.latencyMs,
343
+ resultCount,
344
+ })
345
+ if (settled.outcome.status === 'ok') {
346
+ successCount += 1
347
+ totalResults += resultCount
348
+ collected.push({
349
+ adapterId: id,
350
+ weight: settled.entry.weight,
351
+ results: settled.outcome.results,
352
+ })
353
+ // The top-level answer stays first-wins, but every adapter's prose is
354
+ // kept so a comparison run can show them side by side.
355
+ if (settled.outcome.answer) answersById.set(id, settled.outcome.answer)
356
+ if (answer === null && settled.outcome.answer) answer = settled.outcome.answer
357
+ }
358
+ if (status === 'blocked') blockedAdapters.push(id)
359
+ }
360
+
361
+ const launch = (): void => {
362
+ while (inflight.size < policy.concurrency && queue.length > 0) {
363
+ const entry = queue.shift()
364
+ if (!entry) break
365
+ attempted.add(entry.adapter.id)
366
+ inflight.set(entry.adapter.id, runAdapter(entry, request, adapterDeps))
367
+ }
368
+ }
369
+
370
+ const satisfied = (): boolean => {
371
+ if (policy.settleMode === 'exhaustive') return false
372
+ if (totalResults < policy.minResults) return false
373
+ if (policy.settleMode === 'race') return true
374
+ return successCount >= policy.minAdapters
375
+ }
376
+
377
+ launch()
378
+ while (inflight.size > 0) {
379
+ // Past the soft deadline we only keep waiting while we have nothing at all;
380
+ // holding an answer back for a straggler is worse than answering now.
381
+ const barrier =
382
+ totalResults > 0 && policy.settleMode !== 'exhaustive' ? softDeadlineAt : deadlineAt
383
+ const remaining = barrier - now()
384
+ if (remaining <= 0) break
385
+ const timer = deadlineRace(remaining)
386
+ let winner: AdapterRunResult | typeof DEADLINE_REACHED
387
+ try {
388
+ winner = await Promise.race([...inflight.values(), timer.promise])
389
+ } finally {
390
+ timer.cancel()
391
+ }
392
+ if (winner === DEADLINE_REACHED) break
393
+ absorb(winner)
394
+ if (satisfied()) break
395
+ launch()
396
+ }
397
+
398
+ if (inflight.size > 0 || queue.length > 0) {
399
+ waveController.abort()
400
+ const elapsed = now() - startedAt
401
+ for (const id of inflight.keys()) {
402
+ diagnostics.push({ id, status: 'cancelled', latencyMs: elapsed, resultCount: 0 })
403
+ report('adapter', 'cancelled', id)
404
+ }
405
+ for (const promise of inflight.values()) promise.catch(() => undefined)
406
+ inflight.clear()
407
+ }
408
+ wave.dispose()
409
+
410
+ let escalated = false
411
+ if (
412
+ policy.escalateToBrowser &&
413
+ totalResults < policy.minResults &&
414
+ blockedAdapters.length > 0 &&
415
+ now() < deadlineAt
416
+ ) {
417
+ const entry = browserEntries.find(
418
+ (candidate) =>
419
+ candidate.enabled &&
420
+ candidate.adapter.capabilities.search &&
421
+ candidate.adapter.readiness().ready &&
422
+ !attempted.has(candidate.adapter.id),
423
+ )
424
+ if (entry) {
425
+ attempted.add(entry.adapter.id)
426
+ escalated = true
427
+ report(
428
+ 'adapter',
429
+ 'escalated',
430
+ entry.adapter.id,
431
+ `retrying with a browser after ${blockedAdapters.join(', ')} were blocked`,
432
+ )
433
+ const escalation = linkSignals([runOptions.signal])
434
+ try {
435
+ absorb(await runAdapter(entry, request, { ...adapterDeps, parentSignal: escalation.signal }))
436
+ } finally {
437
+ escalation.dispose()
438
+ }
439
+ }
440
+ }
441
+
442
+ if (totalResults < policy.minResults && policy.lastResort !== null && now() < deadlineAt) {
443
+ const entry = ordered.find(
444
+ (candidate) =>
445
+ candidate.adapter.id === policy.lastResort &&
446
+ candidate.adapter.capabilities.search &&
447
+ !attempted.has(candidate.adapter.id),
448
+ )
449
+ const readiness = entry?.adapter.readiness()
450
+ if (entry && readiness?.ready) {
451
+ attempted.add(entry.adapter.id)
452
+ report('adapter', 'started', entry.adapter.id, 'last-resort adapter')
453
+ const fallback = linkSignals([runOptions.signal])
454
+ try {
455
+ absorb(await runAdapter(entry, request, { ...adapterDeps, parentSignal: fallback.signal }))
456
+ } finally {
457
+ fallback.dispose()
458
+ }
459
+ }
460
+ }
461
+
462
+ const fused = fuseResults(collected, {
463
+ limit: request.limit,
464
+ minResults: policy.minResults,
465
+ minConfidence: policy.minConfidence,
466
+ maxPerDomain: policy.maxPerDomain,
467
+ })
468
+ report('fusion', 'fused', undefined, fused.degraded ? 'confidence threshold relaxed' : undefined, {
469
+ resultCount: fused.results.length,
470
+ })
471
+
472
+ let results = fused.results
473
+ let pagesRead = 0
474
+ if (includeContent && results.length > 0) {
475
+ const expiry = new AbortController()
476
+ const timer = setTimeout(() => expiry.abort(), Math.max(0, deadlineAt - now()))
477
+ timer.unref?.()
478
+ const enrichment = linkSignals([runOptions.signal, expiry.signal])
479
+ try {
480
+ const enriched = await enrichContent(results, enrichment.signal, deadlineAt, (event, detail, metrics) =>
481
+ report('fetch', event, undefined, detail, metrics),
482
+ )
483
+ results = enriched.results
484
+ pagesRead = enriched.pagesRead
485
+ } finally {
486
+ clearTimeout(timer)
487
+ enrichment.dispose()
488
+ }
489
+ }
490
+
491
+ const elapsedMs = now() - startedAt
492
+ report('done', 'completed', undefined, undefined, { latencyMs: elapsedMs, resultCount: results.length })
493
+
494
+ // `degraded` means the engine could not do its job properly, not that the web
495
+ // held few answers. Flagging every run that returned fewer than `minResults`
496
+ // marked a citation-style adapter's normal 1-3 hits as degraded on every
497
+ // call, and an agent reading that reports its own findings as unreliable.
498
+ // `cancelled` is excluded on purpose: race and quorum cancel by design.
499
+ const lostSource = diagnostics.some(
500
+ (entry) => entry.status === 'blocked' || entry.status === 'timeout' || entry.status === 'error',
501
+ )
502
+
503
+ return {
504
+ results,
505
+ answer,
506
+ diagnostics: {
507
+ adapters: diagnostics,
508
+ degraded: fused.degraded || results.length === 0 || lostSource,
509
+ cached: false,
510
+ escalated,
511
+ elapsedMs,
512
+ pagesRead,
513
+ },
514
+ ...(runOptions.includeAdapterResults
515
+ ? {
516
+ byAdapter: collected.map((input) => ({
517
+ adapterId: input.adapterId,
518
+ weight: input.weight,
519
+ answer: answersById.get(input.adapterId) ?? null,
520
+ results: input.results,
521
+ })),
522
+ }
523
+ : {}),
524
+ }
525
+ }
526
+
527
+ async function search(input: SearchRequestInput, runOptions: RunOptions = {}): Promise<SearchEngineResult> {
528
+ const report = createReporter([options.onStep, runOptions.onStep])
529
+ const { request, includeContent } = normalizeRequest(input, policy)
530
+ const adapterIds = ordered.filter((entry) => entry.enabled).map((entry) => entry.adapter.id)
531
+ const compare = runOptions.includeAdapterResults === true
532
+ // The suffix keeps a comparison run off the normal run's single-flight slot,
533
+ // which would otherwise hand one caller a result built for the other shape.
534
+ const cacheKey = `${buildCacheKey(request, adapterIds)}:${includeContent ? 'full' : 'lite'}${compare ? ':compare' : ''}`
535
+ // A comparison is a diagnostic: serving it from cache would show an operator
536
+ // yesterday's adapter behaviour while they change settings, and storing it
537
+ // would put every adapter's full result list in the cache for one debug run.
538
+ const useCache = options.cache !== undefined && policy.cacheTtlMs > 0 && !compare
539
+
540
+ if (useCache) {
541
+ const cached = await options.cache!.get(cacheKey).catch(() => null)
542
+ if (cached) {
543
+ report('done', 'cached', undefined, 'served from cache', { resultCount: cached.results.length })
544
+ // A cache hit read nothing, so it owes nothing to the fetch budget.
545
+ return { ...cached, diagnostics: { ...cached.diagnostics, cached: true, pagesRead: 0 } }
546
+ }
547
+ }
548
+
549
+ const result = await singleFlight(cacheKey, () => runSearch(request, includeContent, runOptions, report))
550
+
551
+ if (useCache && result.results.length > 0) {
552
+ await options.cache!.set(cacheKey, result, policy.cacheTtlMs).catch(() => undefined)
553
+ }
554
+ return result
555
+ }
556
+
557
+ async function health(runOptions: HealthOptions = {}): Promise<readonly AdapterHealthReport[]> {
558
+ const probe = runOptions.probe ?? true
559
+ const budget = PROBE_COST_RANK[runOptions.maxProbeCost ?? 'billable']
560
+ const only = runOptions.only ? new Set(runOptions.only) : null
561
+ return Promise.all(
562
+ ordered.map(async (entry): Promise<AdapterHealthReport> => {
563
+ const probeCost = resolveProbeCost(entry.adapter)
564
+ const readiness = entry.adapter.readiness()
565
+ if (!readiness.ready) {
566
+ return { id: entry.adapter.id, ready: false, ok: false, detail: readiness.reason, probeCost, probed: false }
567
+ }
568
+ // Readiness alone already answers "is this configured", which is what a
569
+ // status display needs. Calling the adapter costs money on a metered
570
+ // source, so it stays opt-in — and the budget keeps an unattended caller
571
+ // to the adapters whose probe is free.
572
+ const withinBudget = PROBE_COST_RANK[probeCost] <= budget && (!only || only.has(entry.adapter.id))
573
+ if (!probe || !withinBudget || !entry.adapter.healthCheck) {
574
+ return { id: entry.adapter.id, ready: true, ok: true, probeCost, probed: false }
575
+ }
576
+ const startedAt = now()
577
+ const controller = new AbortController()
578
+ const timer = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS)
579
+ timer.unref?.()
580
+ const linked = linkSignals([runOptions.signal, controller.signal])
581
+ try {
582
+ const result = await entry.adapter.healthCheck({
583
+ signal: linked.signal,
584
+ http,
585
+ report: () => {},
586
+ deadlineAt: startedAt + HEALTH_TIMEOUT_MS,
587
+ logger,
588
+ })
589
+ return { id: entry.adapter.id, ready: true, latencyMs: now() - startedAt, probeCost, probed: true, ...result }
590
+ } catch (error) {
591
+ return {
592
+ id: entry.adapter.id,
593
+ ready: true,
594
+ ok: false,
595
+ detail: error instanceof Error ? error.message : String(error),
596
+ latencyMs: now() - startedAt,
597
+ probeCost,
598
+ probed: true,
599
+ }
600
+ } finally {
601
+ clearTimeout(timer)
602
+ linked.dispose()
603
+ }
604
+ }),
605
+ )
606
+ }
607
+
608
+ async function dispose(): Promise<void> {
609
+ await Promise.all(
610
+ ordered.map(async (entry) => {
611
+ try {
612
+ await entry.adapter.dispose?.()
613
+ } catch (error) {
614
+ logger.warn(`[web-research] ${entry.adapter.id} failed to dispose`, { error: String(error) })
615
+ }
616
+ }),
617
+ )
618
+ }
619
+
620
+ /**
621
+ * Public single-URL fetch. Wraps `fetchPage` in the same hard deadline so a
622
+ * direct `web_fetch` — which carries no caller signal — is bounded end-to-end
623
+ * (HTTP read AND any browser escalation) at `hardDeadlineMs`, symmetric with the
624
+ * scheduler-bounded search path.
625
+ */
626
+ async function boundedFetch(request: FetchRequest, runOptions: RunOptions = {}): Promise<FetchOutcome> {
627
+ return withFetchDeadline(policy.hardDeadlineMs, runOptions.signal, (signal) =>
628
+ fetchPage(request, { ...runOptions, signal }),
629
+ )
630
+ }
631
+
632
+ return { search, fetch: boundedFetch, health, dispose }
633
+ }