@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.
- package/.turbo/turbo-build.log +2 -0
- package/AGENTS.md +67 -0
- package/build.mjs +10 -0
- package/dist/contract/adapter.js +13 -0
- package/dist/contract/adapter.js.map +7 -0
- package/dist/contract/errors.js +28 -0
- package/dist/contract/errors.js.map +7 -0
- package/dist/contract/http.js +7 -0
- package/dist/contract/http.js.map +7 -0
- package/dist/contract/index.js +9 -0
- package/dist/contract/index.js.map +7 -0
- package/dist/contract/outcomes.js +58 -0
- package/dist/contract/outcomes.js.map +7 -0
- package/dist/contract/policy.js +91 -0
- package/dist/contract/policy.js.map +7 -0
- package/dist/contract/results.js +13 -0
- package/dist/contract/results.js.map +7 -0
- package/dist/contract/steps.js +1 -0
- package/dist/contract/steps.js.map +7 -0
- package/dist/contract/version.js +11 -0
- package/dist/contract/version.js.map +7 -0
- package/dist/engine/cache.js +29 -0
- package/dist/engine/cache.js.map +7 -0
- package/dist/engine/deadline.js +54 -0
- package/dist/engine/deadline.js.map +7 -0
- package/dist/engine/engine.js +481 -0
- package/dist/engine/engine.js.map +7 -0
- package/dist/engine/index.js +20 -0
- package/dist/engine/index.js.map +7 -0
- package/dist/engine/runAdapter.js +57 -0
- package/dist/engine/runAdapter.js.map +7 -0
- package/dist/engine/types.js +1 -0
- package/dist/engine/types.js.map +7 -0
- package/dist/extract/classify.js +51 -0
- package/dist/extract/classify.js.map +7 -0
- package/dist/extract/content.js +104 -0
- package/dist/extract/content.js.map +7 -0
- package/dist/extract/entities.js +95 -0
- package/dist/extract/entities.js.map +7 -0
- package/dist/extract/index.js +24 -0
- package/dist/extract/index.js.map +7 -0
- package/dist/extract/text.js +114 -0
- package/dist/extract/text.js.map +7 -0
- package/dist/extract/tokenizer.js +141 -0
- package/dist/extract/tokenizer.js.map +7 -0
- package/dist/fusion/canonicalize.js +88 -0
- package/dist/fusion/canonicalize.js.map +7 -0
- package/dist/fusion/fuse.js +88 -0
- package/dist/fusion/fuse.js.map +7 -0
- package/dist/fusion/index.js +11 -0
- package/dist/fusion/index.js.map +7 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +7 -0
- package/dist/loader/describe.js +92 -0
- package/dist/loader/describe.js.map +7 -0
- package/dist/loader/index.js +31 -0
- package/dist/loader/index.js.map +7 -0
- package/dist/loader/manifest.js +32 -0
- package/dist/loader/manifest.js.map +7 -0
- package/dist/loader/registry.js +98 -0
- package/dist/loader/registry.js.map +7 -0
- package/dist/net/client.js +298 -0
- package/dist/net/client.js.map +7 -0
- package/dist/net/ssrf.js +165 -0
- package/dist/net/ssrf.js.map +7 -0
- package/dist/net/url.js +9 -0
- package/dist/net/url.js.map +7 -0
- package/dist/testing/contract.js +118 -0
- package/dist/testing/contract.js.map +7 -0
- package/dist/testing/fakeAdapter.js +44 -0
- package/dist/testing/fakeAdapter.js.map +7 -0
- package/dist/testing/index.js +15 -0
- package/dist/testing/index.js.map +7 -0
- package/jest.config.cjs +24 -0
- package/package.json +65 -0
- package/src/contract/adapter.ts +100 -0
- package/src/contract/errors.ts +48 -0
- package/src/contract/http.ts +46 -0
- package/src/contract/index.ts +8 -0
- package/src/contract/outcomes.ts +91 -0
- package/src/contract/policy.ts +157 -0
- package/src/contract/results.ts +79 -0
- package/src/contract/steps.ts +45 -0
- package/src/contract/version.ts +25 -0
- package/src/engine/__tests__/engine.test.ts +597 -0
- package/src/engine/cache.ts +46 -0
- package/src/engine/deadline.ts +65 -0
- package/src/engine/engine.ts +633 -0
- package/src/engine/index.ts +22 -0
- package/src/engine/runAdapter.ts +89 -0
- package/src/engine/types.ts +137 -0
- package/src/extract/__tests__/content.test.ts +81 -0
- package/src/extract/__tests__/text.test.ts +61 -0
- package/src/extract/__tests__/tokenizer.test.ts +85 -0
- package/src/extract/classify.ts +81 -0
- package/src/extract/content.ts +136 -0
- package/src/extract/entities.ts +98 -0
- package/src/extract/index.ts +11 -0
- package/src/extract/text.ts +124 -0
- package/src/extract/tokenizer.ts +197 -0
- package/src/fusion/__tests__/fusion.test.ts +198 -0
- package/src/fusion/canonicalize.ts +119 -0
- package/src/fusion/fuse.ts +145 -0
- package/src/fusion/index.ts +8 -0
- package/src/index.ts +15 -0
- package/src/loader/__tests__/describe.test.ts +119 -0
- package/src/loader/__tests__/registry.test.ts +134 -0
- package/src/loader/describe.ts +145 -0
- package/src/loader/index.ts +25 -0
- package/src/loader/manifest.ts +44 -0
- package/src/loader/registry.ts +156 -0
- package/src/net/__tests__/client.test.ts +273 -0
- package/src/net/__tests__/ssrf.test.ts +165 -0
- package/src/net/client.ts +434 -0
- package/src/net/ssrf.ts +235 -0
- package/src/net/url.ts +13 -0
- package/src/testing/contract.ts +172 -0
- package/src/testing/fakeAdapter.ts +69 -0
- package/src/testing/index.ts +9 -0
- package/tsconfig.json +10 -0
- 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
|
+
}
|