scoutline 0.6.3 → 0.7.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 +8 -9
- package/bin/scoutline.js +2 -3
- package/dist/capabilities/crawl.d.ts +93 -0
- package/dist/capabilities/crawl.d.ts.map +1 -0
- package/dist/capabilities/crawl.js +62 -0
- package/dist/capabilities/crawl.js.map +1 -0
- package/dist/capabilities/diagnostics.d.ts +45 -43
- package/dist/capabilities/diagnostics.d.ts.map +1 -1
- package/dist/capabilities/diagnostics.js +58 -71
- package/dist/capabilities/diagnostics.js.map +1 -1
- package/dist/capabilities/map.d.ts +81 -0
- package/dist/capabilities/map.d.ts.map +1 -0
- package/dist/capabilities/map.js +60 -0
- package/dist/capabilities/map.js.map +1 -0
- package/dist/capabilities/quota.js +1 -1
- package/dist/capabilities/quota.js.map +1 -1
- package/dist/capabilities/research.d.ts +98 -0
- package/dist/capabilities/research.d.ts.map +1 -0
- package/dist/capabilities/research.js +71 -0
- package/dist/capabilities/research.js.map +1 -0
- package/dist/capabilities/search.d.ts +9 -1
- package/dist/capabilities/search.d.ts.map +1 -1
- package/dist/commands/crawl.d.ts +46 -0
- package/dist/commands/crawl.d.ts.map +1 -0
- package/dist/commands/crawl.js +170 -0
- package/dist/commands/crawl.js.map +1 -0
- package/dist/commands/doctor.d.ts +7 -8
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +37 -34
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/map.d.ts +39 -0
- package/dist/commands/map.d.ts.map +1 -0
- package/dist/commands/map.js +121 -0
- package/dist/commands/map.js.map +1 -0
- package/dist/commands/read.d.ts.map +1 -1
- package/dist/commands/read.js +7 -1
- package/dist/commands/read.js.map +1 -1
- package/dist/commands/research.d.ts +62 -0
- package/dist/commands/research.d.ts.map +1 -0
- package/dist/commands/research.js +314 -0
- package/dist/commands/research.js.map +1 -0
- package/dist/commands/search.d.ts +2 -1
- package/dist/commands/search.d.ts.map +1 -1
- package/dist/commands/search.js +11 -4
- package/dist/commands/search.js.map +1 -1
- package/dist/index.d.ts +38 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +260 -13
- package/dist/index.js.map +1 -1
- package/dist/lib/cache.d.ts +13 -0
- package/dist/lib/cache.d.ts.map +1 -1
- package/dist/lib/cache.js +15 -0
- package/dist/lib/cache.js.map +1 -1
- package/dist/lib/execution.d.ts +105 -1
- package/dist/lib/execution.d.ts.map +1 -1
- package/dist/lib/execution.js +73 -1
- package/dist/lib/execution.js.map +1 -1
- package/dist/lib/redact.d.ts +2 -1
- package/dist/lib/redact.d.ts.map +1 -1
- package/dist/lib/redact.js +19 -7
- package/dist/lib/redact.js.map +1 -1
- package/dist/lib/research-state.d.ts +104 -0
- package/dist/lib/research-state.d.ts.map +1 -0
- package/dist/lib/research-state.js +214 -0
- package/dist/lib/research-state.js.map +1 -0
- package/dist/lib/search-topic.d.ts +21 -0
- package/dist/lib/search-topic.d.ts.map +1 -0
- package/dist/lib/search-topic.js +36 -0
- package/dist/lib/search-topic.js.map +1 -0
- package/dist/providers/minimax/adapter.d.ts.map +1 -1
- package/dist/providers/minimax/adapter.js +41 -4
- package/dist/providers/minimax/adapter.js.map +1 -1
- package/dist/providers/minimax/vision-conformance.d.ts +2 -2
- package/dist/providers/minimax/vision-conformance.js +2 -2
- package/dist/providers/registry.d.ts.map +1 -1
- package/dist/providers/registry.js +2 -0
- package/dist/providers/registry.js.map +1 -1
- package/dist/providers/tavily/adapter.d.ts +62 -0
- package/dist/providers/tavily/adapter.d.ts.map +1 -0
- package/dist/providers/tavily/adapter.js +970 -0
- package/dist/providers/tavily/adapter.js.map +1 -0
- package/dist/providers/tavily/client.d.ts +184 -0
- package/dist/providers/tavily/client.d.ts.map +1 -0
- package/dist/providers/tavily/client.js +486 -0
- package/dist/providers/tavily/client.js.map +1 -0
- package/dist/providers/tavily/credentials.d.ts +34 -0
- package/dist/providers/tavily/credentials.d.ts.map +1 -0
- package/dist/providers/tavily/credentials.js +56 -0
- package/dist/providers/tavily/credentials.js.map +1 -0
- package/dist/providers/tavily/diagnostics.d.ts +44 -0
- package/dist/providers/tavily/diagnostics.d.ts.map +1 -0
- package/dist/providers/tavily/diagnostics.js +70 -0
- package/dist/providers/tavily/diagnostics.js.map +1 -0
- package/dist/providers/tavily/quota.d.ts +60 -0
- package/dist/providers/tavily/quota.d.ts.map +1 -0
- package/dist/providers/tavily/quota.js +186 -0
- package/dist/providers/tavily/quota.js.map +1 -0
- package/dist/providers/types.d.ts +14 -2
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/providers/zai/adapter.d.ts.map +1 -1
- package/dist/providers/zai/adapter.js +6 -1
- package/dist/providers/zai/adapter.js.map +1 -1
- package/dist/providers/zai/encoded-error.d.ts.map +1 -1
- package/dist/providers/zai/encoded-error.js +8 -6
- package/dist/providers/zai/encoded-error.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,970 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tavily Provider Adapter (DESIGN.md §5, §7, tech-plan §7).
|
|
3
|
+
*
|
|
4
|
+
* Implements the Tavily Provider Descriptor with Search, Reader, and
|
|
5
|
+
* Crawl capabilities on top of the direct-HTTP transport (`./client.ts`). The
|
|
6
|
+
* Adapter owns credentials, transport lifecycle, Provider field mapping,
|
|
7
|
+
* and failure normalization; shared execution owns cache and retry
|
|
8
|
+
* policy.
|
|
9
|
+
*
|
|
10
|
+
* Boundary rules (ARCHITECTURE.md §2):
|
|
11
|
+
* - May import capability types, normalized errors, Provider identity
|
|
12
|
+
* types, and the Adapter-local credential and transport Modules.
|
|
13
|
+
* - Must NOT import command presentation, output mode, or another
|
|
14
|
+
* Provider's Adapter.
|
|
15
|
+
*
|
|
16
|
+
* Field mapping (tech-plan §7 Tavily mapping):
|
|
17
|
+
* Search results[].title -> title
|
|
18
|
+
* Search results[].url -> url
|
|
19
|
+
* Search results[].content -> summary
|
|
20
|
+
* Search results[].score -> (dropped)
|
|
21
|
+
*
|
|
22
|
+
* Extract results[0].raw_content -> content
|
|
23
|
+
* Extract results[0].url -> finalUrl
|
|
24
|
+
* Extract results[0] -> title: null (Tavily doesn't return one)
|
|
25
|
+
*
|
|
26
|
+
* Control mapping (SearchControls → Tavily-native API params):
|
|
27
|
+
* domain -> include_domains: [domain]
|
|
28
|
+
* recency -> time_range (oneDay→"day", oneWeek→"week",
|
|
29
|
+
* oneMonth→"month", oneYear→"year", noLimit→omit)
|
|
30
|
+
* contentSize -> search_depth (medium→"basic", high→"advanced")
|
|
31
|
+
* topic -> topic (native, pass as-is)
|
|
32
|
+
* location -> REJECTED (UnsupportedOptionError)
|
|
33
|
+
*/
|
|
34
|
+
import crypto from "node:crypto";
|
|
35
|
+
import { decodeReaderFetchResult } from "../../capabilities/reader.js";
|
|
36
|
+
import { decodeCrawlResult } from "../../capabilities/crawl.js";
|
|
37
|
+
import { decodeMapResult } from "../../capabilities/map.js";
|
|
38
|
+
import { decodeResearchResult } from "../../capabilities/research.js";
|
|
39
|
+
import { computeResearchStateHash, createProductionResearchStateFile, } from "../../lib/research-state.js";
|
|
40
|
+
import { ApiError, AuthError, ConfigurationError, NetworkError, QuotaError, TimeoutError, UnsupportedOptionError, ValidationError, } from "../../lib/errors.js";
|
|
41
|
+
import { requireTavilyApiKey, isTavilyConfigured } from "./credentials.js";
|
|
42
|
+
import { fetchTavilySearch, fetchTavilyExtract, fetchTavilyCrawl, fetchTavilyMap, createTavilyResearch, pollTavilyResearch, } from "./client.js";
|
|
43
|
+
import { createTavilyQuotaCapability } from "./quota.js";
|
|
44
|
+
import { createTavilyDiagnosticsCapability } from "./diagnostics.js";
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Provider-owned credential fingerprint
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
function credentialFingerprint(apiKey) {
|
|
49
|
+
return crypto.createHash("sha256").update(apiKey).digest("hex");
|
|
50
|
+
}
|
|
51
|
+
function resolveApiKey(env) {
|
|
52
|
+
return requireTavilyApiKey(env);
|
|
53
|
+
}
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Helpers
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
function isPlainObject(value) {
|
|
58
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
59
|
+
}
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Control mapping (SearchControls → Tavily-native API params)
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
function mapRecencyToTimeRange(recency) {
|
|
64
|
+
switch (recency) {
|
|
65
|
+
case "oneDay":
|
|
66
|
+
return "day";
|
|
67
|
+
case "oneWeek":
|
|
68
|
+
return "week";
|
|
69
|
+
case "oneMonth":
|
|
70
|
+
return "month";
|
|
71
|
+
case "oneYear":
|
|
72
|
+
return "year";
|
|
73
|
+
case "noLimit":
|
|
74
|
+
return undefined;
|
|
75
|
+
default:
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function mapSearchControls(controls) {
|
|
80
|
+
if (!controls)
|
|
81
|
+
return undefined;
|
|
82
|
+
const params = {};
|
|
83
|
+
if (controls.domain) {
|
|
84
|
+
params.include_domains = [controls.domain];
|
|
85
|
+
}
|
|
86
|
+
if (controls.recency) {
|
|
87
|
+
const timeRange = mapRecencyToTimeRange(controls.recency);
|
|
88
|
+
if (timeRange)
|
|
89
|
+
params.time_range = timeRange;
|
|
90
|
+
}
|
|
91
|
+
if (controls.contentSize) {
|
|
92
|
+
params.search_depth = controls.contentSize === "high" ? "advanced" : "basic";
|
|
93
|
+
}
|
|
94
|
+
if (controls.topic) {
|
|
95
|
+
params.topic = controls.topic;
|
|
96
|
+
}
|
|
97
|
+
return params;
|
|
98
|
+
}
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Response normalization
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
/**
|
|
103
|
+
* Normalize a raw Tavily search response into `SearchSource[]`.
|
|
104
|
+
*
|
|
105
|
+
* results[].title -> title
|
|
106
|
+
* results[].url -> url
|
|
107
|
+
* results[].content -> summary
|
|
108
|
+
* results[].score -> (dropped)
|
|
109
|
+
*
|
|
110
|
+
* Any malformed shape is a retryable `ApiError` 500.
|
|
111
|
+
*/
|
|
112
|
+
function normalizeTavilySearchResults(raw) {
|
|
113
|
+
if (!isPlainObject(raw)) {
|
|
114
|
+
throw new ApiError("Tavily search returned a malformed response", 500);
|
|
115
|
+
}
|
|
116
|
+
const results = raw.results;
|
|
117
|
+
if (!Array.isArray(results)) {
|
|
118
|
+
throw new ApiError("Tavily search returned a malformed response", 500);
|
|
119
|
+
}
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const entry of results) {
|
|
122
|
+
if (!isPlainObject(entry)) {
|
|
123
|
+
throw new ApiError("Tavily search returned a malformed response", 500);
|
|
124
|
+
}
|
|
125
|
+
const title = entry.title;
|
|
126
|
+
const url = entry.url;
|
|
127
|
+
const content = entry.content;
|
|
128
|
+
if (typeof title !== "string" || typeof url !== "string" || typeof content !== "string") {
|
|
129
|
+
throw new ApiError("Tavily search returned a malformed response", 500);
|
|
130
|
+
}
|
|
131
|
+
out.push({ title, url, summary: content });
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Normalize a raw Tavily extract response into a `ReaderFetchResult`.
|
|
137
|
+
*
|
|
138
|
+
* results[0].raw_content -> content
|
|
139
|
+
* results[0].url -> finalUrl
|
|
140
|
+
* results[0] -> title: null (Tavily doesn't return a title)
|
|
141
|
+
*
|
|
142
|
+
* If `failed_results` contains the requested URL, throw `ApiError` 422.
|
|
143
|
+
* 422 (Unprocessable Entity) is a terminal 4xx code so the reader does
|
|
144
|
+
* not retry a permanent extraction failure. Any other malformed shape
|
|
145
|
+
* is a retryable `ApiError` 500.
|
|
146
|
+
*/
|
|
147
|
+
function normalizeTavilyExtractResult(raw, request) {
|
|
148
|
+
if (!isPlainObject(raw)) {
|
|
149
|
+
throw new ApiError("Tavily extract returned a malformed response", 500);
|
|
150
|
+
}
|
|
151
|
+
// Check failed_results for the requested URL.
|
|
152
|
+
const failedResults = raw.failed_results;
|
|
153
|
+
if (Array.isArray(failedResults)) {
|
|
154
|
+
for (const f of failedResults) {
|
|
155
|
+
if (isPlainObject(f) && typeof f.url === "string" && f.url === request.url) {
|
|
156
|
+
throw new ApiError("Tavily extract failed for URL", 422);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const results = raw.results;
|
|
161
|
+
if (!Array.isArray(results) || results.length === 0) {
|
|
162
|
+
throw new ApiError("Tavily extract returned a malformed response", 500);
|
|
163
|
+
}
|
|
164
|
+
const first = results[0];
|
|
165
|
+
if (!isPlainObject(first)) {
|
|
166
|
+
throw new ApiError("Tavily extract returned a malformed response", 500);
|
|
167
|
+
}
|
|
168
|
+
const content = first.raw_content;
|
|
169
|
+
if (typeof content !== "string" || content.length === 0) {
|
|
170
|
+
throw new ApiError("Tavily extract returned a malformed response", 500);
|
|
171
|
+
}
|
|
172
|
+
const finalUrl = typeof first.url === "string" && first.url.length > 0 ? first.url : request.url;
|
|
173
|
+
const contentFormat = request.format ?? "markdown";
|
|
174
|
+
return {
|
|
175
|
+
schemaVersion: 1,
|
|
176
|
+
url: request.url,
|
|
177
|
+
finalUrl,
|
|
178
|
+
title: null,
|
|
179
|
+
content,
|
|
180
|
+
contentFormat,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// Failure normalization: stable public codes, no raw payloads (NFR-006)
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
/**
|
|
187
|
+
* Resolve a stable HTTP-style status code for retry classification.
|
|
188
|
+
* Explicit terminal client errors (400, 404, 410, 422) map to their
|
|
189
|
+
* real codes; transient failures map to a representative status in the
|
|
190
|
+
* retryable set (429 or any 5xx 500..599 inclusive, per DESIGN.md §18 /
|
|
191
|
+
* FR-090). Unknown failures default to 500 (transient). When the caller
|
|
192
|
+
* already carries a numeric status (a typed ApiError), that status is
|
|
193
|
+
* honoured directly.
|
|
194
|
+
*/
|
|
195
|
+
function inferStatusCode(lower, known) {
|
|
196
|
+
if (typeof known === "number" && Number.isFinite(known))
|
|
197
|
+
return known;
|
|
198
|
+
if (lower.includes("404") || lower.includes("not found"))
|
|
199
|
+
return 404;
|
|
200
|
+
if (lower.includes("400") || lower.includes("bad request"))
|
|
201
|
+
return 400;
|
|
202
|
+
if (lower.includes("410") || lower.includes("gone"))
|
|
203
|
+
return 410;
|
|
204
|
+
if (lower.includes("422") || lower.includes("unprocessable"))
|
|
205
|
+
return 422;
|
|
206
|
+
if (lower.includes("500") || lower.includes("internal"))
|
|
207
|
+
return 500;
|
|
208
|
+
if (lower.includes("502") || lower.includes("bad gateway"))
|
|
209
|
+
return 502;
|
|
210
|
+
if (lower.includes("503") || lower.includes("service unavailable"))
|
|
211
|
+
return 503;
|
|
212
|
+
if (lower.includes("504") || lower.includes("gateway timeout"))
|
|
213
|
+
return 504;
|
|
214
|
+
return 500;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Status-keyed outward message for rewrapped Tavily ApiErrors. The rewrap
|
|
218
|
+
* does not echo upstream `error.message` — a future change embedding a
|
|
219
|
+
* raw Provider body in an ApiError message would leak through
|
|
220
|
+
* normalization, the cache, and stdout. Curated constants only.
|
|
221
|
+
*/
|
|
222
|
+
function tavilyApiErrorMessage(statusCode) {
|
|
223
|
+
if (statusCode === 429)
|
|
224
|
+
return "Tavily rate limit exceeded";
|
|
225
|
+
if (statusCode === 432) {
|
|
226
|
+
return "Tavily plan limit exceeded. Upgrade your plan at app.tavily.com.";
|
|
227
|
+
}
|
|
228
|
+
if (statusCode === 433) {
|
|
229
|
+
return "Tavily pay-as-you-go limit exceeded. Increase your limit on the Tavily dashboard.";
|
|
230
|
+
}
|
|
231
|
+
return "Tavily request failed";
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Normalize a Provider failure with sanitized messages. Raw response
|
|
235
|
+
* bodies never cross the adapter boundary. Same pattern as
|
|
236
|
+
* `normalizeMiniMaxError`.
|
|
237
|
+
*/
|
|
238
|
+
function normalizeTavilyError(error) {
|
|
239
|
+
// QuotaError pass-through — terminal retry guarantee preserved.
|
|
240
|
+
if (error instanceof QuotaError)
|
|
241
|
+
return error;
|
|
242
|
+
// Configuration/option/validation errors carry clean, human-authored
|
|
243
|
+
// messages and are safe to surface verbatim.
|
|
244
|
+
if (error instanceof ValidationError ||
|
|
245
|
+
error instanceof UnsupportedOptionError ||
|
|
246
|
+
error instanceof ConfigurationError) {
|
|
247
|
+
return error;
|
|
248
|
+
}
|
|
249
|
+
// Re-wrap typed transport errors with sanitized messages so a raw
|
|
250
|
+
// Provider response body embedded upstream never survives. Code +
|
|
251
|
+
// statusCode (retry signal) are preserved.
|
|
252
|
+
if (error instanceof AuthError) {
|
|
253
|
+
return new AuthError("Tavily authentication failed", "TAVILY_API_KEY");
|
|
254
|
+
}
|
|
255
|
+
if (error instanceof NetworkError) {
|
|
256
|
+
return new NetworkError("Tavily network error");
|
|
257
|
+
}
|
|
258
|
+
if (error instanceof TimeoutError) {
|
|
259
|
+
return new TimeoutError(error.durationMs, "Try again or increase timeout with TAVILY_TIMEOUT env var");
|
|
260
|
+
}
|
|
261
|
+
if (error instanceof ApiError) {
|
|
262
|
+
const statusCode = inferStatusCode("", error.statusCode);
|
|
263
|
+
return new ApiError(tavilyApiErrorMessage(statusCode), statusCode);
|
|
264
|
+
}
|
|
265
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
266
|
+
const lower = message.toLowerCase();
|
|
267
|
+
if (lower.includes("401") ||
|
|
268
|
+
lower.includes("403") ||
|
|
269
|
+
lower.includes("unauthorized") ||
|
|
270
|
+
lower.includes("forbidden")) {
|
|
271
|
+
return new AuthError("Tavily authentication failed");
|
|
272
|
+
}
|
|
273
|
+
if (lower.includes("timeout") || lower.includes("timed out") || lower.includes("etimedout")) {
|
|
274
|
+
// Fallback branch: the transport wraps real timeouts into a typed
|
|
275
|
+
// TimeoutError (which carries the configured duration) before they
|
|
276
|
+
// reach this point. This untyped-message heuristic is rarely hit, so
|
|
277
|
+
// a constant default is fine and keeps process.env out of the
|
|
278
|
+
// normalization path (test isolation).
|
|
279
|
+
return new TimeoutError(30000);
|
|
280
|
+
}
|
|
281
|
+
if (lower.includes("econnrefused") ||
|
|
282
|
+
lower.includes("econnreset") ||
|
|
283
|
+
lower.includes("network") ||
|
|
284
|
+
lower.includes("enotfound") ||
|
|
285
|
+
lower.includes("fetch failed")) {
|
|
286
|
+
return new NetworkError("Tavily network error");
|
|
287
|
+
}
|
|
288
|
+
if (lower.includes("429") || lower.includes("rate limit")) {
|
|
289
|
+
return new ApiError("Tavily rate limit exceeded", 429);
|
|
290
|
+
}
|
|
291
|
+
return new ApiError("Tavily request failed", inferStatusCode(lower));
|
|
292
|
+
}
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// Reader validation helpers
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
/** Z.AI-only reader options that Tavily does not accept. */
|
|
297
|
+
const UNSUPPORTED_READER_OPTIONS = [
|
|
298
|
+
"withLinksSummary",
|
|
299
|
+
"noGfm",
|
|
300
|
+
"keepImgDataUrl",
|
|
301
|
+
"withImagesSummary",
|
|
302
|
+
];
|
|
303
|
+
function assertHttpUrl(url) {
|
|
304
|
+
if (typeof url !== "string" || url.length === 0) {
|
|
305
|
+
throw new ValidationError("Tavily reader URL must be a non-empty string");
|
|
306
|
+
}
|
|
307
|
+
if (!/^https?:\/\//.test(url)) {
|
|
308
|
+
throw new ValidationError("URL must start with http:// or https://");
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
function assertNoUnsupportedReaderOptions(request) {
|
|
312
|
+
for (const key of UNSUPPORTED_READER_OPTIONS) {
|
|
313
|
+
// Only reject when the user explicitly enabled the option (`true`).
|
|
314
|
+
// The read command handler sets boolean options to `false` (not
|
|
315
|
+
// `undefined`) when the flag is absent, so `!== undefined` would
|
|
316
|
+
// over-reject. `false` means "user didn't pass the flag" → accept.
|
|
317
|
+
if (request[key] === true) {
|
|
318
|
+
throw new UnsupportedOptionError("tavily", "reader", key);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
function createTavilySearchCapability(options) {
|
|
323
|
+
const { env, transport } = options;
|
|
324
|
+
const capability = {
|
|
325
|
+
validate(request) {
|
|
326
|
+
if (!request || typeof request.query !== "string" || request.query.trim() === "") {
|
|
327
|
+
throw new ValidationError("Search query must contain at least one non-whitespace character");
|
|
328
|
+
}
|
|
329
|
+
// Tavily supports domain, recency, contentSize, and topic natively.
|
|
330
|
+
// location is Z.AI-specific and rejected before any transport call.
|
|
331
|
+
if (request.controls?.location !== undefined) {
|
|
332
|
+
throw new UnsupportedOptionError("tavily", "search", "location");
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
cacheIdentity(request) {
|
|
336
|
+
const apiKey = resolveApiKey(env);
|
|
337
|
+
const identityRequest = {
|
|
338
|
+
query: request.query,
|
|
339
|
+
};
|
|
340
|
+
if (request.controls) {
|
|
341
|
+
identityRequest.controls = request.controls;
|
|
342
|
+
}
|
|
343
|
+
return {
|
|
344
|
+
provider: "tavily",
|
|
345
|
+
capability: "search",
|
|
346
|
+
credentialFingerprint: credentialFingerprint(apiKey),
|
|
347
|
+
request: identityRequest,
|
|
348
|
+
// Tavily never probes legacy keys — no legacyCandidates.
|
|
349
|
+
};
|
|
350
|
+
},
|
|
351
|
+
async invoke(request) {
|
|
352
|
+
capability.validate(request);
|
|
353
|
+
const apiKey = resolveApiKey(env);
|
|
354
|
+
try {
|
|
355
|
+
const params = mapSearchControls(request.controls);
|
|
356
|
+
const raw = await fetchTavilySearch(apiKey, request.query, params, transport);
|
|
357
|
+
return normalizeTavilySearchResults(raw);
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
throw normalizeTavilyError(error);
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
return capability;
|
|
365
|
+
}
|
|
366
|
+
function createTavilyReaderCapability(options) {
|
|
367
|
+
const { env, transport } = options;
|
|
368
|
+
const fetch = {
|
|
369
|
+
kind: "reader-fetch",
|
|
370
|
+
validate(request) {
|
|
371
|
+
assertHttpUrl(request.url);
|
|
372
|
+
assertNoUnsupportedReaderOptions(request);
|
|
373
|
+
},
|
|
374
|
+
cacheIdentity(request) {
|
|
375
|
+
const apiKey = resolveApiKey(env);
|
|
376
|
+
return {
|
|
377
|
+
provider: "tavily",
|
|
378
|
+
capability: "reader",
|
|
379
|
+
operation: "reader-fetch",
|
|
380
|
+
credentialFingerprint: credentialFingerprint(apiKey),
|
|
381
|
+
request,
|
|
382
|
+
legacyCandidates: [],
|
|
383
|
+
};
|
|
384
|
+
},
|
|
385
|
+
decodeCached(value) {
|
|
386
|
+
return decodeReaderFetchResult(value);
|
|
387
|
+
},
|
|
388
|
+
async invoke(request) {
|
|
389
|
+
fetch.validate(request);
|
|
390
|
+
const apiKey = resolveApiKey(env);
|
|
391
|
+
try {
|
|
392
|
+
const raw = await fetchTavilyExtract(apiKey, request.url, undefined, transport);
|
|
393
|
+
return normalizeTavilyExtractResult(raw, request);
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
throw normalizeTavilyError(error);
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
};
|
|
400
|
+
return { fetch };
|
|
401
|
+
}
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
// Crawl Capability
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
/**
|
|
406
|
+
* Split a comma-separated path-pattern string into an array, trimming
|
|
407
|
+
* whitespace from each entry. Returns `undefined` for empty/whitespace-
|
|
408
|
+
* only input.
|
|
409
|
+
*/
|
|
410
|
+
function splitPathPatterns(value) {
|
|
411
|
+
if (value === undefined || value.trim() === "")
|
|
412
|
+
return undefined;
|
|
413
|
+
return value
|
|
414
|
+
.split(",")
|
|
415
|
+
.map((s) => s.trim())
|
|
416
|
+
.filter((s) => s.length > 0);
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Map a Provider-neutral `CrawlRequest` into Tavily-native API params.
|
|
420
|
+
*
|
|
421
|
+
* url -> url (handled by transport)
|
|
422
|
+
* depth -> max_depth
|
|
423
|
+
* breadth -> max_breadth
|
|
424
|
+
* limit -> limit
|
|
425
|
+
* selectPaths -> select_paths (split on comma if string)
|
|
426
|
+
* excludePaths -> exclude_paths (split on comma if string)
|
|
427
|
+
* instructions -> instructions
|
|
428
|
+
* format -> format
|
|
429
|
+
* contentSize -> extract_depth (medium→"basic", high→"advanced")
|
|
430
|
+
* timeout -> timeout
|
|
431
|
+
*/
|
|
432
|
+
function mapCrawlControls(request) {
|
|
433
|
+
const params = {};
|
|
434
|
+
if (request.depth !== undefined)
|
|
435
|
+
params.max_depth = request.depth;
|
|
436
|
+
if (request.breadth !== undefined)
|
|
437
|
+
params.max_breadth = request.breadth;
|
|
438
|
+
if (request.limit !== undefined)
|
|
439
|
+
params.limit = request.limit;
|
|
440
|
+
const selectPaths = splitPathPatterns(request.selectPaths);
|
|
441
|
+
if (selectPaths !== undefined)
|
|
442
|
+
params.select_paths = selectPaths;
|
|
443
|
+
const excludePaths = splitPathPatterns(request.excludePaths);
|
|
444
|
+
if (excludePaths !== undefined)
|
|
445
|
+
params.exclude_paths = excludePaths;
|
|
446
|
+
if (request.instructions !== undefined)
|
|
447
|
+
params.instructions = request.instructions;
|
|
448
|
+
if (request.format !== undefined)
|
|
449
|
+
params.format = request.format;
|
|
450
|
+
if (request.contentSize !== undefined) {
|
|
451
|
+
params.extract_depth = request.contentSize === "high" ? "advanced" : "basic";
|
|
452
|
+
}
|
|
453
|
+
if (request.timeout !== undefined)
|
|
454
|
+
params.timeout = request.timeout;
|
|
455
|
+
return params;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Normalize a raw Tavily crawl response into a `CrawlResult`.
|
|
459
|
+
*
|
|
460
|
+
* results[].url -> page.url
|
|
461
|
+
* results[].raw_content -> page.content
|
|
462
|
+
* format -> page.contentFormat (from request, default markdown)
|
|
463
|
+
* baseUrl -> the request URL
|
|
464
|
+
* totalPages -> results array length
|
|
465
|
+
*
|
|
466
|
+
* Any malformed shape is a retryable `ApiError` 500.
|
|
467
|
+
*/
|
|
468
|
+
function normalizeTavilyCrawlResult(raw, request) {
|
|
469
|
+
if (!isPlainObject(raw)) {
|
|
470
|
+
throw new ApiError("Tavily crawl returned a malformed response", 500);
|
|
471
|
+
}
|
|
472
|
+
const results = raw.results;
|
|
473
|
+
if (!Array.isArray(results)) {
|
|
474
|
+
throw new ApiError("Tavily crawl returned a malformed response", 500);
|
|
475
|
+
}
|
|
476
|
+
const contentFormat = request.format ?? "markdown";
|
|
477
|
+
const pages = [];
|
|
478
|
+
for (const entry of results) {
|
|
479
|
+
if (!isPlainObject(entry)) {
|
|
480
|
+
throw new ApiError("Tavily crawl returned a malformed response", 500);
|
|
481
|
+
}
|
|
482
|
+
const url = entry.url;
|
|
483
|
+
const content = entry.raw_content;
|
|
484
|
+
if (typeof url !== "string" || typeof content !== "string") {
|
|
485
|
+
throw new ApiError("Tavily crawl returned a malformed response", 500);
|
|
486
|
+
}
|
|
487
|
+
pages.push({ url, content, contentFormat });
|
|
488
|
+
}
|
|
489
|
+
return {
|
|
490
|
+
schemaVersion: 1,
|
|
491
|
+
baseUrl: request.url,
|
|
492
|
+
pages,
|
|
493
|
+
totalPages: pages.length,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
function createTavilyCrawlCapability(options) {
|
|
497
|
+
const { env, transport } = options;
|
|
498
|
+
const fetch = {
|
|
499
|
+
kind: "crawl-fetch",
|
|
500
|
+
validate(request) {
|
|
501
|
+
assertHttpUrl(request.url);
|
|
502
|
+
if (request.depth !== undefined) {
|
|
503
|
+
if (!Number.isInteger(request.depth) || request.depth < 1 || request.depth > 5) {
|
|
504
|
+
throw new ValidationError("Crawl depth must be an integer between 1 and 5");
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (request.breadth !== undefined) {
|
|
508
|
+
if (!Number.isInteger(request.breadth) || request.breadth < 1 || request.breadth > 500) {
|
|
509
|
+
throw new ValidationError("Crawl breadth must be an integer between 1 and 500");
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (request.limit !== undefined && request.limit <= 0) {
|
|
513
|
+
throw new ValidationError("Crawl limit must be greater than 0");
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
cacheIdentity(request) {
|
|
517
|
+
const apiKey = resolveApiKey(env);
|
|
518
|
+
return {
|
|
519
|
+
provider: "tavily",
|
|
520
|
+
capability: "crawl",
|
|
521
|
+
credentialFingerprint: credentialFingerprint(apiKey),
|
|
522
|
+
request,
|
|
523
|
+
};
|
|
524
|
+
},
|
|
525
|
+
decodeCached(value) {
|
|
526
|
+
return decodeCrawlResult(value);
|
|
527
|
+
},
|
|
528
|
+
async invoke(request) {
|
|
529
|
+
fetch.validate(request);
|
|
530
|
+
const apiKey = resolveApiKey(env);
|
|
531
|
+
try {
|
|
532
|
+
const params = mapCrawlControls(request);
|
|
533
|
+
const raw = await fetchTavilyCrawl(apiKey, request.url, params, transport);
|
|
534
|
+
return normalizeTavilyCrawlResult(raw, request);
|
|
535
|
+
}
|
|
536
|
+
catch (error) {
|
|
537
|
+
throw normalizeTavilyError(error);
|
|
538
|
+
}
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
return { fetch };
|
|
542
|
+
}
|
|
543
|
+
// ---------------------------------------------------------------------------
|
|
544
|
+
// Map Capability
|
|
545
|
+
// ---------------------------------------------------------------------------
|
|
546
|
+
/**
|
|
547
|
+
* Map a Provider-neutral `MapRequest` into Tavily-native API params.
|
|
548
|
+
*
|
|
549
|
+
* url -> url (handled by transport)
|
|
550
|
+
* depth -> max_depth
|
|
551
|
+
* breadth -> max_breadth
|
|
552
|
+
* limit -> limit
|
|
553
|
+
* selectPaths -> select_paths (split on comma if string)
|
|
554
|
+
* excludePaths -> exclude_paths (split on comma if string)
|
|
555
|
+
* instructions -> instructions
|
|
556
|
+
*
|
|
557
|
+
* Map returns URLs only — no `format`, `extract_depth`, or `timeout` are
|
|
558
|
+
* sent on the /map request body.
|
|
559
|
+
*/
|
|
560
|
+
function mapMapControls(request) {
|
|
561
|
+
const params = {};
|
|
562
|
+
if (request.depth !== undefined)
|
|
563
|
+
params.max_depth = request.depth;
|
|
564
|
+
if (request.breadth !== undefined)
|
|
565
|
+
params.max_breadth = request.breadth;
|
|
566
|
+
if (request.limit !== undefined)
|
|
567
|
+
params.limit = request.limit;
|
|
568
|
+
const selectPaths = splitPathPatterns(request.selectPaths);
|
|
569
|
+
if (selectPaths !== undefined)
|
|
570
|
+
params.select_paths = selectPaths;
|
|
571
|
+
const excludePaths = splitPathPatterns(request.excludePaths);
|
|
572
|
+
if (excludePaths !== undefined)
|
|
573
|
+
params.exclude_paths = excludePaths;
|
|
574
|
+
if (request.instructions !== undefined)
|
|
575
|
+
params.instructions = request.instructions;
|
|
576
|
+
return params;
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Normalize a raw Tavily map response into a `MapResult`.
|
|
580
|
+
*
|
|
581
|
+
* results (string[]) -> urls
|
|
582
|
+
* baseUrl -> the request URL
|
|
583
|
+
* totalUrls -> results array length
|
|
584
|
+
*
|
|
585
|
+
* Any malformed shape is a retryable `ApiError` 500.
|
|
586
|
+
*/
|
|
587
|
+
function normalizeTavilyMapResult(raw, request) {
|
|
588
|
+
if (!isPlainObject(raw)) {
|
|
589
|
+
throw new ApiError("Tavily map returned a malformed response", 500);
|
|
590
|
+
}
|
|
591
|
+
const results = raw.results;
|
|
592
|
+
if (!Array.isArray(results)) {
|
|
593
|
+
throw new ApiError("Tavily map returned a malformed response", 500);
|
|
594
|
+
}
|
|
595
|
+
const urls = [];
|
|
596
|
+
for (const entry of results) {
|
|
597
|
+
if (typeof entry !== "string" || entry.length === 0) {
|
|
598
|
+
throw new ApiError("Tavily map returned a malformed response", 500);
|
|
599
|
+
}
|
|
600
|
+
urls.push(entry);
|
|
601
|
+
}
|
|
602
|
+
return {
|
|
603
|
+
schemaVersion: 1,
|
|
604
|
+
baseUrl: request.url,
|
|
605
|
+
urls,
|
|
606
|
+
totalUrls: urls.length,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
function createTavilyMapCapability(options) {
|
|
610
|
+
const { env, transport } = options;
|
|
611
|
+
const fetch = {
|
|
612
|
+
kind: "map-fetch",
|
|
613
|
+
validate(request) {
|
|
614
|
+
assertHttpUrl(request.url);
|
|
615
|
+
if (request.depth !== undefined) {
|
|
616
|
+
if (!Number.isInteger(request.depth) || request.depth < 1 || request.depth > 5) {
|
|
617
|
+
throw new ValidationError("Map depth must be an integer between 1 and 5");
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
if (request.breadth !== undefined) {
|
|
621
|
+
if (!Number.isInteger(request.breadth) || request.breadth < 1 || request.breadth > 500) {
|
|
622
|
+
throw new ValidationError("Map breadth must be an integer between 1 and 500");
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
if (request.limit !== undefined && request.limit <= 0) {
|
|
626
|
+
throw new ValidationError("Map limit must be greater than 0");
|
|
627
|
+
}
|
|
628
|
+
},
|
|
629
|
+
cacheIdentity(request) {
|
|
630
|
+
const apiKey = resolveApiKey(env);
|
|
631
|
+
return {
|
|
632
|
+
provider: "tavily",
|
|
633
|
+
capability: "map",
|
|
634
|
+
credentialFingerprint: credentialFingerprint(apiKey),
|
|
635
|
+
request,
|
|
636
|
+
};
|
|
637
|
+
},
|
|
638
|
+
decodeCached(value) {
|
|
639
|
+
return decodeMapResult(value);
|
|
640
|
+
},
|
|
641
|
+
async invoke(request) {
|
|
642
|
+
fetch.validate(request);
|
|
643
|
+
const apiKey = resolveApiKey(env);
|
|
644
|
+
try {
|
|
645
|
+
const params = mapMapControls(request);
|
|
646
|
+
const raw = await fetchTavilyMap(apiKey, request.url, params, transport);
|
|
647
|
+
return normalizeTavilyMapResult(raw, request);
|
|
648
|
+
}
|
|
649
|
+
catch (error) {
|
|
650
|
+
throw normalizeTavilyError(error);
|
|
651
|
+
}
|
|
652
|
+
},
|
|
653
|
+
};
|
|
654
|
+
return { fetch };
|
|
655
|
+
}
|
|
656
|
+
// ---------------------------------------------------------------------------
|
|
657
|
+
// Research Capability (tech-plan §2c, §3)
|
|
658
|
+
// ---------------------------------------------------------------------------
|
|
659
|
+
/**
|
|
660
|
+
* Default polling interval between GET /research/{id} calls. Overridable
|
|
661
|
+
* via `TAVILY_RESEARCH_POLL_INTERVAL_MS` in the transport env so tests
|
|
662
|
+
* can poll instantly.
|
|
663
|
+
*/
|
|
664
|
+
const DEFAULT_RESEARCH_POLL_INTERVAL_MS = 5000;
|
|
665
|
+
function resolvePollIntervalMs(env) {
|
|
666
|
+
const raw = env?.TAVILY_RESEARCH_POLL_INTERVAL_MS;
|
|
667
|
+
const parsed = parseInt(raw ?? "", 10);
|
|
668
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_RESEARCH_POLL_INTERVAL_MS;
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* Build a `sleep(ms)` from the injected timers so tests that pass fake
|
|
672
|
+
* `setTimeout`/`clearTimeout` through the transport deps also control
|
|
673
|
+
* poll-loop timing. A non-positive interval resolves immediately.
|
|
674
|
+
*
|
|
675
|
+
* When `signal` is supplied, the sleep is abortable: if the signal is
|
|
676
|
+
* already aborted (or aborts while sleeping), the pending timer is
|
|
677
|
+
* cleared and the promise rejects with a `TimeoutError`. This lets the
|
|
678
|
+
* research poll loop unwind promptly when the command handler's
|
|
679
|
+
* `--timeout` fires, so lingering `setTimeout`s do not keep the event
|
|
680
|
+
* loop alive and freeze the CLI. The rejection is swallowed by the
|
|
681
|
+
* command handler's late-rejection guard.
|
|
682
|
+
*/
|
|
683
|
+
function makeSleep(deps, signal) {
|
|
684
|
+
const setT = deps?.setTimeout ?? setTimeout;
|
|
685
|
+
const clearT = deps?.clearTimeout ?? clearTimeout;
|
|
686
|
+
return (ms) => new Promise((resolve, reject) => {
|
|
687
|
+
if (signal?.aborted) {
|
|
688
|
+
reject(new TimeoutError(0, "Research polling aborted"));
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
if (ms <= 0) {
|
|
692
|
+
// Yield to the event loop even for a zero interval so the poll
|
|
693
|
+
// loop never starves macrotasks (tests, signal handlers).
|
|
694
|
+
setImmediate(() => {
|
|
695
|
+
if (signal?.aborted) {
|
|
696
|
+
reject(new TimeoutError(0, "Research polling aborted"));
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
resolve();
|
|
700
|
+
});
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
const onAbort = () => {
|
|
704
|
+
clearT(id);
|
|
705
|
+
reject(new TimeoutError(0, "Research polling aborted"));
|
|
706
|
+
};
|
|
707
|
+
const id = setT(() => {
|
|
708
|
+
signal?.removeEventListener("abort", onAbort);
|
|
709
|
+
resolve();
|
|
710
|
+
}, ms);
|
|
711
|
+
signal?.addEventListener("abort", onAbort);
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
function isEexistError(err) {
|
|
715
|
+
return (typeof err === "object" &&
|
|
716
|
+
err !== null &&
|
|
717
|
+
"code" in err &&
|
|
718
|
+
err.code === "EEXIST");
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Map a Provider-neutral `ResearchRequest` into Tavily-native API params
|
|
722
|
+
* (minus the query, which the transport adds).
|
|
723
|
+
*
|
|
724
|
+
* model -> model
|
|
725
|
+
* outputLength -> output_length
|
|
726
|
+
* citationFormat -> citation_format
|
|
727
|
+
* domain -> domain
|
|
728
|
+
*/
|
|
729
|
+
function mapResearchControls(request) {
|
|
730
|
+
const params = {};
|
|
731
|
+
if (request.model !== undefined)
|
|
732
|
+
params.model = request.model;
|
|
733
|
+
if (request.outputLength !== undefined)
|
|
734
|
+
params.output_length = request.outputLength;
|
|
735
|
+
if (request.citationFormat !== undefined)
|
|
736
|
+
params.citation_format = request.citationFormat;
|
|
737
|
+
if (request.domain !== undefined)
|
|
738
|
+
params.domain = request.domain;
|
|
739
|
+
return params;
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Normalize a completed Tavily research poll result into a
|
|
743
|
+
* `ResearchResult`.
|
|
744
|
+
*
|
|
745
|
+
* content -> report
|
|
746
|
+
* sources[].title -> sources[].title
|
|
747
|
+
* sources[].url -> sources[].url (favicon dropped)
|
|
748
|
+
* model -> echoed from the request (default "auto")
|
|
749
|
+
*/
|
|
750
|
+
function normalizeTavilyResearchResult(poll, request) {
|
|
751
|
+
const sources = [];
|
|
752
|
+
if (poll.sources) {
|
|
753
|
+
for (const entry of poll.sources) {
|
|
754
|
+
// Drop sources missing title or url rather than failing the whole
|
|
755
|
+
// report — a partial source list is still useful.
|
|
756
|
+
if (entry.title && entry.url) {
|
|
757
|
+
sources.push({ title: entry.title, url: entry.url });
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
return {
|
|
762
|
+
schemaVersion: 1,
|
|
763
|
+
query: request.query,
|
|
764
|
+
model: request.model ?? "auto",
|
|
765
|
+
report: poll.content ?? "",
|
|
766
|
+
sources,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
function createTavilyResearchCapability(options) {
|
|
770
|
+
const { env, transport, researchStateFile } = options;
|
|
771
|
+
const run = {
|
|
772
|
+
kind: "research-fetch",
|
|
773
|
+
validate(request) {
|
|
774
|
+
if (!request || typeof request.query !== "string" || request.query.trim() === "") {
|
|
775
|
+
throw new ValidationError("Research query must contain at least one non-whitespace character");
|
|
776
|
+
}
|
|
777
|
+
},
|
|
778
|
+
cacheIdentity(request) {
|
|
779
|
+
const apiKey = resolveApiKey(env);
|
|
780
|
+
return {
|
|
781
|
+
provider: "tavily",
|
|
782
|
+
capability: "research",
|
|
783
|
+
credentialFingerprint: credentialFingerprint(apiKey),
|
|
784
|
+
request,
|
|
785
|
+
};
|
|
786
|
+
},
|
|
787
|
+
decodeCached(value) {
|
|
788
|
+
return decodeResearchResult(value);
|
|
789
|
+
},
|
|
790
|
+
async invoke(request, signal) {
|
|
791
|
+
run.validate(request);
|
|
792
|
+
const apiKey = resolveApiKey(env);
|
|
793
|
+
const credFingerprint = credentialFingerprint(apiKey);
|
|
794
|
+
const identityHash = computeResearchStateHash({
|
|
795
|
+
provider: "tavily",
|
|
796
|
+
capability: "research",
|
|
797
|
+
credentialFingerprint: credFingerprint,
|
|
798
|
+
request,
|
|
799
|
+
});
|
|
800
|
+
const pollIntervalMs = resolvePollIntervalMs(transport?.env);
|
|
801
|
+
// Abortable sleep: when `signal` aborts (the command handler's
|
|
802
|
+
// `--timeout` fired), the pending poll-interval timer is cleared
|
|
803
|
+
// and rejects, unwinding the loop so the process can exit.
|
|
804
|
+
const sleep = makeSleep(transport, signal);
|
|
805
|
+
try {
|
|
806
|
+
// 1. Check for an in-flight task (resume after Ctrl-C / crash).
|
|
807
|
+
// A valid state file with a pending/in_progress status means a
|
|
808
|
+
// task was already created server-side — poll it instead of
|
|
809
|
+
// creating a second one (double-charge prevention).
|
|
810
|
+
const existingState = await researchStateFile.read(identityHash);
|
|
811
|
+
let requestId;
|
|
812
|
+
if (existingState !== null) {
|
|
813
|
+
// Resume the existing task — no new POST.
|
|
814
|
+
requestId = existingState.requestId;
|
|
815
|
+
}
|
|
816
|
+
else {
|
|
817
|
+
// 2. No in-flight task: POST to create one. NO retry — a
|
|
818
|
+
// transient POST failure is terminal (the user re-runs);
|
|
819
|
+
// retrying risks a double-charge if the POST succeeded
|
|
820
|
+
// server-side but the response was lost.
|
|
821
|
+
requestId = await createResearchTask(apiKey, request, identityHash, researchStateFile, transport);
|
|
822
|
+
}
|
|
823
|
+
// 3. Poll loop until terminal status.
|
|
824
|
+
for (;;) {
|
|
825
|
+
// Re-check the abort signal each iteration. If the command
|
|
826
|
+
// handler's --timeout fired during the create POST or between
|
|
827
|
+
// sleeps, exit immediately rather than issuing another poll.
|
|
828
|
+
if (signal?.aborted) {
|
|
829
|
+
throw new TimeoutError(0, "Research polling aborted");
|
|
830
|
+
}
|
|
831
|
+
const poll = await pollTavilyResearch(apiKey, requestId, transport);
|
|
832
|
+
if (poll.status === "completed") {
|
|
833
|
+
// Success — delete the state file and return the result.
|
|
834
|
+
await researchStateFile.remove(identityHash);
|
|
835
|
+
return normalizeTavilyResearchResult(poll, request);
|
|
836
|
+
}
|
|
837
|
+
if (poll.status === "failed") {
|
|
838
|
+
// Server-side failure — delete the state file and throw.
|
|
839
|
+
await researchStateFile.remove(identityHash);
|
|
840
|
+
throw new ApiError("Tavily research task failed", 500);
|
|
841
|
+
}
|
|
842
|
+
if (poll.status === "not_found") {
|
|
843
|
+
// 404 — the server-side task expired/disappeared. Delete the
|
|
844
|
+
// stale state file and create a fresh task, then continue
|
|
845
|
+
// polling. The state file is removed first so the `wx`-flag
|
|
846
|
+
// write in createResearchTask succeeds.
|
|
847
|
+
await researchStateFile.remove(identityHash);
|
|
848
|
+
requestId = await createResearchTask(apiKey, request, identityHash, researchStateFile, transport);
|
|
849
|
+
continue;
|
|
850
|
+
}
|
|
851
|
+
// pending or in_progress: sleep and poll again. The state file
|
|
852
|
+
// already holds the requestId; we intentionally do NOT rewrite
|
|
853
|
+
// it here — the `wx` flag only allows creation, and the
|
|
854
|
+
// requestId (not the transient status) is the load-bearing
|
|
855
|
+
// field for resume. The transient poll status is therefore not
|
|
856
|
+
// persisted, which is why no reassignment is needed here.
|
|
857
|
+
await sleep(pollIntervalMs);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
catch (error) {
|
|
861
|
+
throw normalizeTavilyError(error);
|
|
862
|
+
}
|
|
863
|
+
},
|
|
864
|
+
};
|
|
865
|
+
return { run };
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* POST /research to create a task, then persist its requestId in the
|
|
869
|
+
* state file atomically. On EEXIST (a concurrent invocation already
|
|
870
|
+
* created a task for this request), read the existing state file and
|
|
871
|
+
* return its requestId instead — the concurrent task is polled, not
|
|
872
|
+
* duplicated.
|
|
873
|
+
*/
|
|
874
|
+
async function createResearchTask(apiKey, request, identityHash, stateFile, transport) {
|
|
875
|
+
const params = mapResearchControls(request);
|
|
876
|
+
const created = await createTavilyResearch(apiKey, request.query, params, transport);
|
|
877
|
+
const requestId = created.requestId;
|
|
878
|
+
const state = {
|
|
879
|
+
requestId,
|
|
880
|
+
identityHash,
|
|
881
|
+
createdAt: new Date().toISOString(),
|
|
882
|
+
status: "pending",
|
|
883
|
+
};
|
|
884
|
+
try {
|
|
885
|
+
await stateFile.write(identityHash, state);
|
|
886
|
+
}
|
|
887
|
+
catch (err) {
|
|
888
|
+
if (isEexistError(err)) {
|
|
889
|
+
// Concurrent invocation won the race — poll its task instead.
|
|
890
|
+
const existing = await stateFile.read(identityHash);
|
|
891
|
+
if (existing !== null) {
|
|
892
|
+
return existing.requestId;
|
|
893
|
+
}
|
|
894
|
+
// The existing file was corrupt (read returned null after deleting
|
|
895
|
+
// it). Fall through and poll the task we just created — it is
|
|
896
|
+
// valid server-side even if we cannot persist it.
|
|
897
|
+
}
|
|
898
|
+
else {
|
|
899
|
+
throw err;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
return requestId;
|
|
903
|
+
}
|
|
904
|
+
// ---------------------------------------------------------------------------
|
|
905
|
+
// Descriptor factory
|
|
906
|
+
// ---------------------------------------------------------------------------
|
|
907
|
+
/**
|
|
908
|
+
* Build the Tavily Provider Descriptor. The descriptor advertises the
|
|
909
|
+
* full Tavily capability set and constructs an Adapter whose `search`
|
|
910
|
+
* and `reader` Capabilities own credentials, transport, Provider field
|
|
911
|
+
* mapping, and failure normalization. Construction is side-effect-free;
|
|
912
|
+
* the transport is invoked per Capability call. Tests pass `transport`
|
|
913
|
+
* (typically a fake-fetch wrapper); production uses the no-argument
|
|
914
|
+
* factory which resolves to the global `fetch` and timers inside the
|
|
915
|
+
* transport Module.
|
|
916
|
+
*/
|
|
917
|
+
export function createTavilyDescriptor(dependencies) {
|
|
918
|
+
const transport = dependencies?.transport;
|
|
919
|
+
const researchStateFile = dependencies?.researchStateFile ?? createProductionResearchStateFile();
|
|
920
|
+
return {
|
|
921
|
+
id: "tavily",
|
|
922
|
+
isConfigured(env) {
|
|
923
|
+
return isTavilyConfigured(env);
|
|
924
|
+
},
|
|
925
|
+
capabilities() {
|
|
926
|
+
return new Set([
|
|
927
|
+
"search",
|
|
928
|
+
"reader",
|
|
929
|
+
"crawl",
|
|
930
|
+
"map",
|
|
931
|
+
"research",
|
|
932
|
+
"quota",
|
|
933
|
+
"diagnostics",
|
|
934
|
+
]);
|
|
935
|
+
},
|
|
936
|
+
create(context) {
|
|
937
|
+
const search = createTavilySearchCapability({
|
|
938
|
+
env: context.env,
|
|
939
|
+
transport,
|
|
940
|
+
});
|
|
941
|
+
const reader = createTavilyReaderCapability({
|
|
942
|
+
env: context.env,
|
|
943
|
+
transport,
|
|
944
|
+
});
|
|
945
|
+
const crawl = createTavilyCrawlCapability({
|
|
946
|
+
env: context.env,
|
|
947
|
+
transport,
|
|
948
|
+
});
|
|
949
|
+
const map = createTavilyMapCapability({
|
|
950
|
+
env: context.env,
|
|
951
|
+
transport,
|
|
952
|
+
});
|
|
953
|
+
const research = createTavilyResearchCapability({
|
|
954
|
+
env: context.env,
|
|
955
|
+
transport,
|
|
956
|
+
researchStateFile,
|
|
957
|
+
});
|
|
958
|
+
const quota = createTavilyQuotaCapability({
|
|
959
|
+
env: context.env,
|
|
960
|
+
transport,
|
|
961
|
+
});
|
|
962
|
+
const diagnostics = createTavilyDiagnosticsCapability({
|
|
963
|
+
env: context.env,
|
|
964
|
+
transport,
|
|
965
|
+
});
|
|
966
|
+
return { id: "tavily", search, reader, crawl, map, research, quota, diagnostics };
|
|
967
|
+
},
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
//# sourceMappingURL=adapter.js.map
|