@dbx-tools/appkit-web-search 0.3.29 → 0.3.30
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 +125 -29
- package/index.ts +4 -2
- package/package.json +6 -6
- package/src/allowlist.ts +5 -2
- package/src/config.ts +218 -33
- package/src/defaults.ts +127 -0
- package/src/fetch.ts +48 -21
- package/src/html-text.ts +3 -3
- package/src/plugin.ts +217 -25
- package/src/provider.ts +28 -5
- package/src/runtime.ts +87 -3
- package/src/schema.ts +22 -0
- package/src/scrape.ts +32 -11
- package/src/search.ts +131 -47
- package/src/tool.ts +43 -31
- package/test/allowlist.test.ts +69 -3
package/src/config.ts
CHANGED
|
@@ -13,19 +13,38 @@
|
|
|
13
13
|
* {@link WebSearchPluginConfig.modelFallbacks} order (Gemini, then GPT, then a
|
|
14
14
|
* repo floor) picks the first web-search-capable endpoint that exists.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* built-in default.
|
|
16
|
+
* Which endpoint id is used follows the standard precedence - explicit plugin
|
|
17
|
+
* config, then environment, then a default - with two environment sources:
|
|
19
18
|
*
|
|
20
|
-
*
|
|
21
|
-
* `
|
|
19
|
+
* 1. `model` in plugin config;
|
|
20
|
+
* 2. `WEB_SEARCH_MODEL`, the dedicated override, so a deployment can point
|
|
21
|
+
* web search at a different endpoint than the agent's chat model;
|
|
22
|
+
* 3. `DATABRICKS_SERVING_ENDPOINT_NAME`, AppKit's standard name for a
|
|
23
|
+
* Model Serving binding, honored so the resource declared in the manifest
|
|
24
|
+
* wires this plugin up like any other. Because that binding is shared
|
|
25
|
+
* with whatever else the app serves, it is treated as a preference: an
|
|
26
|
+
* endpoint that cannot run the native web-search tool is skipped in
|
|
27
|
+
* favor of {@link WebSearchPluginConfig.modelFallbacks} rather than
|
|
28
|
+
* failing the call. A pin from (1) or (2) is explicit and DOES fail;
|
|
29
|
+
* 4. otherwise the fallback order, resolved against the live catalogue.
|
|
30
|
+
*
|
|
31
|
+
* Which model wins is decided lazily, at call time, against the live
|
|
32
|
+
* catalogue. Resolution here is eager about everything else and fails loudly
|
|
33
|
+
* on a contradiction: an unparseable `WEB_SEARCH_TOOLS`, an unknown
|
|
34
|
+
* {@link UrlPolicyMode}, or a URL policy that disagrees with the allow-list it
|
|
35
|
+
* was given.
|
|
36
|
+
*
|
|
37
|
+
* Env fallbacks: `WEB_SEARCH_MODEL`, `DATABRICKS_SERVING_ENDPOINT_NAME`,
|
|
38
|
+
* `WEB_SEARCH_MODEL_FALLBACKS`, `WEB_SEARCH_TOOLS` (JSON),
|
|
39
|
+
* `WEB_SEARCH_URL_POLICY`, `WEB_SEARCH_ALLOWED_URLS`,
|
|
22
40
|
* `WEB_SEARCH_MAX_CITATIONS`, `WEB_SEARCH_FETCH_MAX_LENGTH`,
|
|
23
|
-
* `WEB_SEARCH_TIMEOUT_MS`, `
|
|
41
|
+
* `WEB_SEARCH_TIMEOUT_MS`, `WEB_SEARCH_SCRAPE_FALLBACK`, `WEB_SEARCH_FUZZY`,
|
|
42
|
+
* `WEB_SEARCH_FUZZY_THRESHOLD`.
|
|
24
43
|
*
|
|
25
44
|
* @module
|
|
26
45
|
*/
|
|
27
46
|
|
|
28
|
-
import type
|
|
47
|
+
import { ConfigurationError, type BasePluginConfig } from "@databricks/appkit";
|
|
29
48
|
import { serving } from "@dbx-tools/model";
|
|
30
49
|
import { json, object, type OneOrMany, string } from "@dbx-tools/shared-core";
|
|
31
50
|
import type { JSONSchema7 } from "json-schema";
|
|
@@ -36,10 +55,37 @@ import { parseAllowedUrls, toUrlAllowList, type UrlAllowList } from "./allowlist
|
|
|
36
55
|
* pattern (or list of patterns, in the {@link OneOrMany} shape used across
|
|
37
56
|
* the repo) gates only calls whose URL matches. Patterns use the same glob
|
|
38
57
|
* syntax as the allow-list (see `allowlist.ts`). Omit / `false` for no
|
|
39
|
-
* approval.
|
|
58
|
+
* approval. Normalized to an {@link ApprovalPolicy} before use.
|
|
40
59
|
*/
|
|
41
60
|
export type ApprovalGate = boolean | OneOrMany<string> | string;
|
|
42
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The normalized form of an {@link ApprovalGate}: which calls pause for a
|
|
64
|
+
* human. `"none"` runs every call straight through, `"always"` gates all of
|
|
65
|
+
* them, and `"urls"` gates only calls whose URL matches one of `patterns`.
|
|
66
|
+
*/
|
|
67
|
+
export type ApprovalPolicy =
|
|
68
|
+
| { readonly mode: "none" }
|
|
69
|
+
| { readonly mode: "always" }
|
|
70
|
+
| { readonly mode: "urls"; readonly patterns: readonly string[] };
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Which URLs the tools may reach. `"allowlist"` permits only the configured
|
|
74
|
+
* entries; `"unrestricted"` names the permissive mode explicitly, so an
|
|
75
|
+
* unrestricted deployment is a stated choice visible in the boot log rather
|
|
76
|
+
* than an empty list nobody noticed.
|
|
77
|
+
*/
|
|
78
|
+
export type UrlPolicyMode = "unrestricted" | "allowlist";
|
|
79
|
+
|
|
80
|
+
/** Dedicated override for the web-search endpoint id. */
|
|
81
|
+
export const MODEL_ENV = "WEB_SEARCH_MODEL";
|
|
82
|
+
|
|
83
|
+
/** AppKit's standard environment name for a Model Serving endpoint binding. */
|
|
84
|
+
export const SERVING_ENDPOINT_ENV = "DATABRICKS_SERVING_ENDPOINT_NAME";
|
|
85
|
+
|
|
86
|
+
/** Where the pinned web-search endpoint id came from, or `"none"` when unpinned. */
|
|
87
|
+
export type ModelSource = "config" | typeof MODEL_ENV | typeof SERVING_ENDPOINT_ENV | "none";
|
|
88
|
+
|
|
43
89
|
/**
|
|
44
90
|
* Default web-search model preference, tried in order when no model is
|
|
45
91
|
* pinned. Gemini first, then GPT - both support the native web-search tool;
|
|
@@ -69,8 +115,9 @@ export interface WebSearchPluginConfig extends BasePluginConfig {
|
|
|
69
115
|
* The web-search model to use by default: a Databricks serving endpoint
|
|
70
116
|
* name (`"databricks-gemini-3-pro"`), a loose name (`"gemini"`, `"gpt"`),
|
|
71
117
|
* or a capability class. Fuzzy-matched against the live catalogue. Falls
|
|
72
|
-
* back to `WEB_SEARCH_MODEL`, then
|
|
73
|
-
* independently of the calling
|
|
118
|
+
* back to `WEB_SEARCH_MODEL`, then `DATABRICKS_SERVING_ENDPOINT_NAME`, then
|
|
119
|
+
* the {@link modelFallbacks} order. Chosen independently of the calling
|
|
120
|
+
* agent's chat model.
|
|
74
121
|
*/
|
|
75
122
|
model?: string;
|
|
76
123
|
/**
|
|
@@ -120,6 +167,15 @@ export interface WebSearchPluginConfig extends BasePluginConfig {
|
|
|
120
167
|
* require a native model and error otherwise.
|
|
121
168
|
*/
|
|
122
169
|
scrapeFallback?: boolean;
|
|
170
|
+
/**
|
|
171
|
+
* Which URLs the tools may reach ({@link UrlPolicyMode}). Falls back to
|
|
172
|
+
* `WEB_SEARCH_URL_POLICY`, then to `"allowlist"` when {@link allowedUrls}
|
|
173
|
+
* has entries and `"unrestricted"` when it does not. Naming the mode
|
|
174
|
+
* explicitly is the way to state that an open deployment is intended;
|
|
175
|
+
* `"allowlist"` with no entries, or `"unrestricted"` alongside entries, is
|
|
176
|
+
* a contradiction and fails at resolution.
|
|
177
|
+
*/
|
|
178
|
+
urlPolicy?: UrlPolicyMode;
|
|
123
179
|
/**
|
|
124
180
|
* Optional URL allow-list. Each entry is a glob (or bare host) tested
|
|
125
181
|
* against a URL's full `href`. When set, `web_search` silently filters
|
|
@@ -132,15 +188,18 @@ export interface WebSearchPluginConfig extends BasePluginConfig {
|
|
|
132
188
|
/**
|
|
133
189
|
* Approval gate applied to BOTH tools (per-tool overrides via
|
|
134
190
|
* {@link WebSearchToolOptions.approval} win). `true` gates every call;
|
|
135
|
-
* a URL-pattern (or list) gates only matching calls
|
|
191
|
+
* a URL-pattern (or list) gates only matching calls; an
|
|
192
|
+
* {@link ApprovalPolicy} states the mode directly. Omit for no approval.
|
|
136
193
|
*/
|
|
137
|
-
approval?: ApprovalGate;
|
|
194
|
+
approval?: ApprovalGate | ApprovalPolicy;
|
|
138
195
|
}
|
|
139
196
|
|
|
140
197
|
/** Concrete, validated config the runtime + tools read. */
|
|
141
198
|
export interface ResolvedWebSearchConfig {
|
|
142
199
|
/** Pinned web-search model, when configured (else undefined - use fallbacks). */
|
|
143
200
|
model?: string;
|
|
201
|
+
/** Where {@link model} came from, which decides whether an unsupported pin is fatal. */
|
|
202
|
+
modelSource: ModelSource;
|
|
144
203
|
/** Ordered fallback model candidates (Gemini, then GPT, then a floor). */
|
|
145
204
|
modelFallbacks: readonly string[];
|
|
146
205
|
/** Provider -> tool-spec override map, merged over the built-in defaults. */
|
|
@@ -154,10 +213,12 @@ export interface ResolvedWebSearchConfig {
|
|
|
154
213
|
timeoutMs: number;
|
|
155
214
|
/** Whether to scrape-fallback when no native web-search model is deployed. */
|
|
156
215
|
scrapeFallback: boolean;
|
|
157
|
-
/**
|
|
216
|
+
/** The named URL policy in force. */
|
|
217
|
+
urlPolicy: UrlPolicyMode;
|
|
218
|
+
/** Compiled allow-list (permit-all under the `unrestricted` policy). */
|
|
158
219
|
allowList: UrlAllowList;
|
|
159
|
-
/** Default per-tool approval
|
|
160
|
-
approval:
|
|
220
|
+
/** Default per-tool approval policy. */
|
|
221
|
+
approval: ApprovalPolicy;
|
|
161
222
|
}
|
|
162
223
|
|
|
163
224
|
/** JSON Schema published on the manifest's `config.schema`. */
|
|
@@ -167,7 +228,7 @@ export const WEB_SEARCH_CONFIG_SCHEMA: JSONSchema7 = {
|
|
|
167
228
|
model: {
|
|
168
229
|
type: "string",
|
|
169
230
|
description:
|
|
170
|
-
"Default web-search model (endpoint name, loose name, or class). Fuzzy-matched. Env: WEB_SEARCH_MODEL.",
|
|
231
|
+
"Default web-search model (endpoint name, loose name, or class). Fuzzy-matched. Env: WEB_SEARCH_MODEL, then DATABRICKS_SERVING_ENDPOINT_NAME.",
|
|
171
232
|
},
|
|
172
233
|
modelFallbacks: {
|
|
173
234
|
type: "array",
|
|
@@ -180,6 +241,16 @@ export const WEB_SEARCH_CONFIG_SCHEMA: JSONSchema7 = {
|
|
|
180
241
|
description:
|
|
181
242
|
'Provider -> tool-spec override map merged over the built-in defaults (openai -> {"type":"web_search"}, gemini -> {"google_search":{}}). Env: WEB_SEARCH_TOOLS (JSON).',
|
|
182
243
|
},
|
|
244
|
+
modelFuzzyMatch: {
|
|
245
|
+
type: "boolean",
|
|
246
|
+
description:
|
|
247
|
+
"Fuzzy-match loose model names against the live catalogue. Default true (env: WEB_SEARCH_FUZZY).",
|
|
248
|
+
},
|
|
249
|
+
modelFuzzyThreshold: {
|
|
250
|
+
type: "number",
|
|
251
|
+
description:
|
|
252
|
+
"Fuse.js score threshold below which a fuzzy model match is accepted (env: WEB_SEARCH_FUZZY_THRESHOLD).",
|
|
253
|
+
},
|
|
183
254
|
maxCitations: {
|
|
184
255
|
type: "number",
|
|
185
256
|
description: "Hard cap on citations returned (env: WEB_SEARCH_MAX_CITATIONS).",
|
|
@@ -192,12 +263,40 @@ export const WEB_SEARCH_CONFIG_SCHEMA: JSONSchema7 = {
|
|
|
192
263
|
type: "number",
|
|
193
264
|
description: "Per-request network timeout in ms (env: WEB_SEARCH_TIMEOUT_MS).",
|
|
194
265
|
},
|
|
266
|
+
scrapeFallback: {
|
|
267
|
+
type: "boolean",
|
|
268
|
+
description:
|
|
269
|
+
"Fall back to a DuckDuckGo scrape when no web-search-capable model is deployed. Default true (env: WEB_SEARCH_SCRAPE_FALLBACK).",
|
|
270
|
+
},
|
|
271
|
+
urlPolicy: {
|
|
272
|
+
type: "string",
|
|
273
|
+
enum: ["unrestricted", "allowlist"],
|
|
274
|
+
description:
|
|
275
|
+
"Which URLs the tools may reach. Defaults to allowlist when allowedUrls has entries, else unrestricted (env: WEB_SEARCH_URL_POLICY).",
|
|
276
|
+
},
|
|
195
277
|
allowedUrls: {
|
|
196
278
|
type: "array",
|
|
197
279
|
items: { type: "string" },
|
|
198
280
|
description:
|
|
199
281
|
'URL allow-list of globs / bare hosts (e.g. "*.databricks.com", "docs.example.com"). Also accepts a comma/space-separated string. Falls back to WEB_SEARCH_ALLOWED_URLS. Empty = unrestricted.',
|
|
200
282
|
},
|
|
283
|
+
approval: {
|
|
284
|
+
description:
|
|
285
|
+
'Approval gate for both tools: true gates every call, a glob (or list of globs) gates only matching URLs, or state the mode directly as {"mode":"none"|"always"|"urls"}. Default none.',
|
|
286
|
+
oneOf: [
|
|
287
|
+
{ type: "boolean" },
|
|
288
|
+
{ type: "string" },
|
|
289
|
+
{ type: "array", items: { type: "string" } },
|
|
290
|
+
{
|
|
291
|
+
type: "object",
|
|
292
|
+
properties: {
|
|
293
|
+
mode: { type: "string", enum: ["none", "always", "urls"] },
|
|
294
|
+
patterns: { type: "array", items: { type: "string" } },
|
|
295
|
+
},
|
|
296
|
+
required: ["mode"],
|
|
297
|
+
},
|
|
298
|
+
],
|
|
299
|
+
},
|
|
201
300
|
},
|
|
202
301
|
};
|
|
203
302
|
|
|
@@ -208,22 +307,103 @@ function resolvePositiveInt(value: number | undefined, envKey: string, fallback:
|
|
|
208
307
|
return Number.isFinite(env) && env > 0 ? Math.floor(env) : fallback;
|
|
209
308
|
}
|
|
210
309
|
|
|
211
|
-
/**
|
|
310
|
+
/**
|
|
311
|
+
* Parse the `WEB_SEARCH_TOOLS` env var (JSON object), else `{}`. A value that
|
|
312
|
+
* is set but not a JSON object is a deployment mistake that would otherwise
|
|
313
|
+
* silently leave the built-in tool specs in place, so it throws.
|
|
314
|
+
*/
|
|
212
315
|
function parseToolsEnv(): Record<string, unknown> {
|
|
213
|
-
|
|
316
|
+
const raw = string.trimToNull(process.env.WEB_SEARCH_TOOLS);
|
|
317
|
+
if (raw === null) return {};
|
|
318
|
+
const parsed = json.parseRecord(raw);
|
|
319
|
+
if (!parsed) {
|
|
320
|
+
throw new ConfigurationError(
|
|
321
|
+
"WEB_SEARCH_TOOLS must be a JSON object mapping a provider family to its tool spec",
|
|
322
|
+
{ context: { envVar: "WEB_SEARCH_TOOLS" } },
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
return parsed;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Read the pinned endpoint id and record which source supplied it. */
|
|
329
|
+
function resolveModelPin(config: WebSearchPluginConfig): { model?: string; source: ModelSource } {
|
|
330
|
+
const fromConfig = string.trimToNull(config.model);
|
|
331
|
+
if (fromConfig !== null) return { model: fromConfig, source: "config" };
|
|
332
|
+
const fromModelEnv = string.trimToNull(process.env[MODEL_ENV]);
|
|
333
|
+
if (fromModelEnv !== null) return { model: fromModelEnv, source: MODEL_ENV };
|
|
334
|
+
const fromResourceEnv = string.trimToNull(process.env[SERVING_ENDPOINT_ENV]);
|
|
335
|
+
if (fromResourceEnv !== null) return { model: fromResourceEnv, source: SERVING_ENDPOINT_ENV };
|
|
336
|
+
return { source: "none" };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Resolve the named {@link UrlPolicyMode}, failing on a stated mode that
|
|
341
|
+
* contradicts the allow-list entries it was given.
|
|
342
|
+
*/
|
|
343
|
+
function resolveUrlPolicy(
|
|
344
|
+
configured: UrlPolicyMode | undefined,
|
|
345
|
+
patterns: readonly string[],
|
|
346
|
+
): UrlPolicyMode {
|
|
347
|
+
const raw = configured ?? string.trimToNull(process.env.WEB_SEARCH_URL_POLICY) ?? undefined;
|
|
348
|
+
if (raw === undefined) return patterns.length > 0 ? "allowlist" : "unrestricted";
|
|
349
|
+
if (raw !== "allowlist" && raw !== "unrestricted") {
|
|
350
|
+
throw new ConfigurationError(
|
|
351
|
+
'urlPolicy must be "allowlist" or "unrestricted" (env: WEB_SEARCH_URL_POLICY)',
|
|
352
|
+
{ context: { field: "urlPolicy", envVar: "WEB_SEARCH_URL_POLICY" } },
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
if (raw === "allowlist" && patterns.length === 0) {
|
|
356
|
+
throw new ConfigurationError(
|
|
357
|
+
'urlPolicy "allowlist" needs at least one entry in allowedUrls (env: WEB_SEARCH_ALLOWED_URLS)',
|
|
358
|
+
{ context: { field: "allowedUrls", envVar: "WEB_SEARCH_ALLOWED_URLS" } },
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
if (raw === "unrestricted" && patterns.length > 0) {
|
|
362
|
+
throw new ConfigurationError(
|
|
363
|
+
'urlPolicy "unrestricted" cannot be combined with allowedUrls entries; drop the entries or set urlPolicy to "allowlist"',
|
|
364
|
+
{ context: { field: "allowedUrls", envVar: "WEB_SEARCH_ALLOWED_URLS" } },
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
return raw;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Normalize any accepted approval spelling into an {@link ApprovalPolicy}.
|
|
372
|
+
* A pattern gate with no usable patterns collapses to `"none"` rather than
|
|
373
|
+
* gating everything, so a blank env var can never wedge the tools behind an
|
|
374
|
+
* approval nobody configured.
|
|
375
|
+
*/
|
|
376
|
+
export function toApprovalPolicy(gate: ApprovalGate | ApprovalPolicy | undefined): ApprovalPolicy {
|
|
377
|
+
if (gate === undefined || gate === false) return { mode: "none" };
|
|
378
|
+
if (gate === true) return { mode: "always" };
|
|
379
|
+
if (object.isRecord(gate) && "mode" in gate) {
|
|
380
|
+
if (gate.mode === "urls") {
|
|
381
|
+
const patterns = parseAllowedUrls([...gate.patterns]);
|
|
382
|
+
return patterns.length > 0 ? { mode: "urls", patterns } : { mode: "none" };
|
|
383
|
+
}
|
|
384
|
+
if (gate.mode === "always" || gate.mode === "none") return { mode: gate.mode };
|
|
385
|
+
throw new ConfigurationError('approval mode must be "none", "always" or "urls"', {
|
|
386
|
+
context: { field: "approval" },
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
const patterns = parseAllowedUrls(typeof gate === "string" ? gate : [...gate]);
|
|
390
|
+
return patterns.length > 0 ? { mode: "urls", patterns } : { mode: "none" };
|
|
214
391
|
}
|
|
215
392
|
|
|
216
393
|
/**
|
|
217
394
|
* Resolve plugin config over environment defaults into the concrete
|
|
218
|
-
* {@link ResolvedWebSearchConfig}.
|
|
219
|
-
*
|
|
220
|
-
*
|
|
395
|
+
* {@link ResolvedWebSearchConfig}. Which model is used stays lazy - it is
|
|
396
|
+
* picked at call time against the live catalogue - but everything else is
|
|
397
|
+
* settled here, and a contradiction (unparseable `WEB_SEARCH_TOOLS`, an
|
|
398
|
+
* unknown URL policy, a policy that disagrees with its allow-list) throws a
|
|
399
|
+
* {@link ConfigurationError} naming the field and the environment variable.
|
|
221
400
|
*/
|
|
222
401
|
export function resolveWebSearchConfig(
|
|
223
402
|
config: WebSearchPluginConfig = {},
|
|
224
403
|
): ResolvedWebSearchConfig {
|
|
225
404
|
const patterns = parseAllowedUrls(config.allowedUrls ?? process.env.WEB_SEARCH_ALLOWED_URLS);
|
|
226
|
-
const
|
|
405
|
+
const urlPolicy = resolveUrlPolicy(config.urlPolicy, patterns);
|
|
406
|
+
const { model, source } = resolveModelPin(config);
|
|
227
407
|
const fallbacks = string.parseList(
|
|
228
408
|
config.modelFallbacks ?? process.env.WEB_SEARCH_MODEL_FALLBACKS,
|
|
229
409
|
);
|
|
@@ -231,6 +411,7 @@ export function resolveWebSearchConfig(
|
|
|
231
411
|
config.modelFuzzyThreshold ?? Number(process.env.WEB_SEARCH_FUZZY_THRESHOLD);
|
|
232
412
|
return {
|
|
233
413
|
...(model ? { model } : {}),
|
|
414
|
+
modelSource: source,
|
|
234
415
|
modelFallbacks: fallbacks.length > 0 ? fallbacks : DEFAULT_MODEL_FALLBACKS,
|
|
235
416
|
webSearchTools: { ...parseToolsEnv(), ...(config.webSearchTools ?? {}) },
|
|
236
417
|
fuzzy: config.modelFuzzyMatch ?? object.toBoolean(process.env.WEB_SEARCH_FUZZY) ?? true,
|
|
@@ -251,22 +432,26 @@ export function resolveWebSearchConfig(
|
|
|
251
432
|
timeoutMs: resolvePositiveInt(config.timeoutMs, "WEB_SEARCH_TIMEOUT_MS", DEFAULT_TIMEOUT_MS),
|
|
252
433
|
scrapeFallback:
|
|
253
434
|
config.scrapeFallback ?? object.toBoolean(process.env.WEB_SEARCH_SCRAPE_FALLBACK) ?? true,
|
|
254
|
-
|
|
255
|
-
|
|
435
|
+
urlPolicy,
|
|
436
|
+
allowList: toUrlAllowList(urlPolicy === "allowlist" ? patterns : []),
|
|
437
|
+
approval: toApprovalPolicy(config.approval),
|
|
256
438
|
};
|
|
257
439
|
}
|
|
258
440
|
|
|
259
441
|
/**
|
|
260
|
-
* Resolve an
|
|
261
|
-
*
|
|
262
|
-
* when ANY candidate matches. Empty candidates with a pattern gate
|
|
263
|
-
* match (nothing to approve). Reuses the allow-list matcher so approval
|
|
442
|
+
* Resolve an approval gate against a set of candidate URLs into a concrete
|
|
443
|
+
* boolean: `"none"` / `"always"` pass straight through; a `"urls"` policy
|
|
444
|
+
* gates when ANY candidate matches. Empty candidates with a pattern gate
|
|
445
|
+
* never match (nothing to approve). Reuses the allow-list matcher so approval
|
|
264
446
|
* globs read exactly like allow-list globs.
|
|
265
447
|
*/
|
|
266
|
-
export function approvalMatches(
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
const
|
|
448
|
+
export function approvalMatches(
|
|
449
|
+
gate: ApprovalGate | ApprovalPolicy,
|
|
450
|
+
urls: readonly string[],
|
|
451
|
+
): boolean {
|
|
452
|
+
const policy = toApprovalPolicy(gate);
|
|
453
|
+
if (policy.mode === "none") return false;
|
|
454
|
+
if (policy.mode === "always") return true;
|
|
455
|
+
const list = toUrlAllowList(policy.patterns);
|
|
271
456
|
return urls.some((url) => list.allows(url));
|
|
272
457
|
}
|
package/src/defaults.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interceptor settings for the add-on's three outbound calls: the native
|
|
3
|
+
* web-search POST to Model Serving, the `web_fetch` page read, and the
|
|
4
|
+
* DuckDuckGo scrape fallback. They live here, in the sibling `defaults.ts`
|
|
5
|
+
* AppKit plugins keep, so a TTL or retry budget is tuned in one place instead
|
|
6
|
+
* of at a call site.
|
|
7
|
+
*
|
|
8
|
+
* All three are idempotent READS: none of them create or mutate anything, so
|
|
9
|
+
* both caching and retrying are safe. Each constant below records why its
|
|
10
|
+
* numbers were chosen.
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The `PluginExecuteConfig` slice this package sets. Mirrored structurally
|
|
17
|
+
* because AppKit's `PluginExecuteConfig` lives behind a subpath its `exports`
|
|
18
|
+
* map does not publish, so the nominal type cannot be imported.
|
|
19
|
+
*/
|
|
20
|
+
export type WebSearchExecuteConfig = {
|
|
21
|
+
cache?: { enabled?: boolean; ttl?: number; cacheKey?: (string | number | object)[] };
|
|
22
|
+
retry?: { enabled?: boolean; attempts?: number; initialDelay?: number; maxDelay?: number };
|
|
23
|
+
timeout?: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The `PluginExecutionSettings` shape accepted by AppKit's `Plugin.execute()`.
|
|
28
|
+
* Mirrored structurally for the same reason as {@link WebSearchExecuteConfig}.
|
|
29
|
+
*/
|
|
30
|
+
export type WebSearchExecutionSettings = {
|
|
31
|
+
default: WebSearchExecuteConfig;
|
|
32
|
+
user?: WebSearchExecuteConfig;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Cache lifetime for a native web-search answer. Long enough that a model
|
|
37
|
+
* re-asking the same question inside one conversation does not pay for a
|
|
38
|
+
* second serving call, short enough that "current information" stays current.
|
|
39
|
+
*/
|
|
40
|
+
export const SEARCH_CACHE_TTL_SECONDS = 300;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Cache lifetime for a fetched page. Pages move more slowly than search
|
|
44
|
+
* results, and an agent commonly re-reads the same source across turns.
|
|
45
|
+
*/
|
|
46
|
+
export const FETCH_CACHE_TTL_SECONDS = 900;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Attempts for a serving call. Model Serving sheds load with 429 / 503 under
|
|
50
|
+
* contention; three attempts with AppKit's exponential backoff and full jitter
|
|
51
|
+
* clears that without turning a genuine outage into a long stall.
|
|
52
|
+
*/
|
|
53
|
+
export const SERVING_RETRY_ATTEMPTS = 3;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Attempts for an open-web request. Kept lower than the serving budget: a
|
|
57
|
+
* remote site that refuses once usually refuses again, and the caller is
|
|
58
|
+
* waiting on an interactive tool call.
|
|
59
|
+
*/
|
|
60
|
+
export const WEB_RETRY_ATTEMPTS = 2;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Settings for the native web-search POST to Model Serving.
|
|
64
|
+
*
|
|
65
|
+
* Cache enabled: the POST is a pure read of the model's answer for one query,
|
|
66
|
+
* and repeat asks of the same question are common in a multi-turn agent.
|
|
67
|
+
* Retry enabled: the POST creates no conversation or state, so replaying it
|
|
68
|
+
* after a transient 429 / 5xx is safe.
|
|
69
|
+
*/
|
|
70
|
+
export const webSearchExecuteDefaults: WebSearchExecutionSettings = {
|
|
71
|
+
default: {
|
|
72
|
+
cache: { enabled: true, ttl: SEARCH_CACHE_TTL_SECONDS },
|
|
73
|
+
retry: { enabled: true, attempts: SERVING_RETRY_ATTEMPTS },
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Settings for a `web_fetch` page read.
|
|
79
|
+
*
|
|
80
|
+
* Cache enabled: fetching a URL is a read, and an agent that searches then
|
|
81
|
+
* reads its own citations hits the same page repeatedly.
|
|
82
|
+
* Retry enabled: a GET is idempotent, so a dropped connection is worth one
|
|
83
|
+
* more attempt.
|
|
84
|
+
*/
|
|
85
|
+
export const webFetchExecuteDefaults: WebSearchExecutionSettings = {
|
|
86
|
+
default: {
|
|
87
|
+
cache: { enabled: true, ttl: FETCH_CACHE_TTL_SECONDS },
|
|
88
|
+
retry: { enabled: true, attempts: WEB_RETRY_ATTEMPTS },
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Settings for the DuckDuckGo scrape fallback. Same read semantics as the
|
|
94
|
+
* native search, and the shorter cache lifetime applies for the same reason,
|
|
95
|
+
* but the retry budget follows the open-web one: DuckDuckGo answers a repeat
|
|
96
|
+
* request with a bot challenge rather than results.
|
|
97
|
+
*/
|
|
98
|
+
export const scrapeSearchExecuteDefaults: WebSearchExecutionSettings = {
|
|
99
|
+
default: {
|
|
100
|
+
cache: { enabled: true, ttl: SEARCH_CACHE_TTL_SECONDS },
|
|
101
|
+
retry: { enabled: true, attempts: WEB_RETRY_ATTEMPTS },
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Bind one of the named defaults to a single call: the configured network
|
|
107
|
+
* timeout and the cache key for this request.
|
|
108
|
+
*
|
|
109
|
+
* AppKit only installs the cache interceptor when a non-empty `cacheKey` is
|
|
110
|
+
* present, so every cached call has to name its own key parts. The per-user
|
|
111
|
+
* half of the key is added by `execute()` from the active execution context,
|
|
112
|
+
* which is what keeps an OBO result from leaking across identities.
|
|
113
|
+
*/
|
|
114
|
+
export function toCallSettings(
|
|
115
|
+
base: WebSearchExecutionSettings,
|
|
116
|
+
timeoutMs: number,
|
|
117
|
+
cacheKey: readonly (string | number)[],
|
|
118
|
+
): WebSearchExecutionSettings {
|
|
119
|
+
return {
|
|
120
|
+
default: {
|
|
121
|
+
...base.default,
|
|
122
|
+
cache: { ...base.default.cache, cacheKey: [...cacheKey] },
|
|
123
|
+
timeout: timeoutMs,
|
|
124
|
+
},
|
|
125
|
+
...(base.user ? { user: base.user } : {}),
|
|
126
|
+
};
|
|
127
|
+
}
|
package/src/fetch.ts
CHANGED
|
@@ -18,12 +18,22 @@
|
|
|
18
18
|
import { log } from "@dbx-tools/shared-core";
|
|
19
19
|
import { gotScraping } from "got-scraping";
|
|
20
20
|
import { assertUrlAllowed } from "./allowlist";
|
|
21
|
-
import { decodeHtmlEntities, htmlToText } from "./html-text";
|
|
22
21
|
import type { ResolvedWebSearchConfig } from "./config";
|
|
22
|
+
import { toCallSettings, webFetchExecuteDefaults } from "./defaults";
|
|
23
|
+
import { decodeHtmlEntities, htmlToText } from "./html-text";
|
|
24
|
+
import { executeRead } from "./runtime";
|
|
23
25
|
import type { WebFetchRequest, WebFetchResult } from "./schema";
|
|
24
26
|
|
|
25
27
|
const logger = log.logger("web-search/fetch");
|
|
26
28
|
|
|
29
|
+
/** The slice of a got-scraping response this module reads. */
|
|
30
|
+
interface FetchedPage {
|
|
31
|
+
url: string;
|
|
32
|
+
statusCode: number;
|
|
33
|
+
contentType: string | undefined;
|
|
34
|
+
body: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
27
37
|
/** Pull the <title> text out of an HTML document, when present. */
|
|
28
38
|
function extractTitle(html: string): string | undefined {
|
|
29
39
|
const match = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html);
|
|
@@ -41,43 +51,60 @@ function truncate(text: string, max: number): { content: string; truncated: bool
|
|
|
41
51
|
* Fetch a single URL and return its content in the requested format.
|
|
42
52
|
*
|
|
43
53
|
* Throws when the URL is not permitted by the allow-list (the visible,
|
|
44
|
-
* correctable failure the design calls for on the fetch path).
|
|
45
|
-
*
|
|
46
|
-
*
|
|
54
|
+
* correctable failure the design calls for on the fetch path). The request's
|
|
55
|
+
* `maxLength` narrows (never widens) the plugin's `fetchMaxLength` cap.
|
|
56
|
+
* `signal` cancels the in-flight fetch.
|
|
47
57
|
*/
|
|
48
58
|
export async function runWebFetch(
|
|
49
59
|
request: WebFetchRequest,
|
|
50
60
|
config: ResolvedWebSearchConfig,
|
|
61
|
+
signal?: AbortSignal,
|
|
51
62
|
): Promise<WebFetchResult> {
|
|
52
63
|
assertUrlAllowed(request.url, config.allowList);
|
|
53
64
|
const cap = Math.min(request.maxLength ?? config.fetchMaxLength, config.fetchMaxLength);
|
|
54
65
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
66
|
+
// The response is cached before the format reduction, so the same page read
|
|
67
|
+
// as text and as html shares one network round trip.
|
|
68
|
+
const page = await executeRead(
|
|
69
|
+
"page-fetch",
|
|
70
|
+
toCallSettings(webFetchExecuteDefaults, config.timeoutMs, ["web-search", "fetch", request.url]),
|
|
71
|
+
async (executeSignal): Promise<FetchedPage> => {
|
|
72
|
+
const response = await gotScraping({
|
|
73
|
+
url: request.url,
|
|
74
|
+
// got's own timeout aborts the socket and reports which phase timed
|
|
75
|
+
// out; the interceptor timeout bounds the whole attempt around it.
|
|
76
|
+
timeout: { request: config.timeoutMs },
|
|
77
|
+
throwHttpErrors: false,
|
|
78
|
+
followRedirect: true,
|
|
79
|
+
...(executeSignal ? { signal: executeSignal } : {}),
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
url: response.url ?? request.url,
|
|
83
|
+
statusCode: response.statusCode,
|
|
84
|
+
contentType: response.headers["content-type"],
|
|
85
|
+
body: typeof response.body === "string" ? response.body : String(response.body ?? ""),
|
|
86
|
+
};
|
|
87
|
+
},
|
|
88
|
+
signal,
|
|
89
|
+
);
|
|
61
90
|
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
const isHtml = !contentType || /html|xml/i.test(contentType);
|
|
65
|
-
const rawContent = request.format === "html" || !isHtml ? body : htmlToText(body);
|
|
91
|
+
const isHtml = !page.contentType || /html|xml/i.test(page.contentType);
|
|
92
|
+
const rawContent = request.format === "html" || !isHtml ? page.body : htmlToText(page.body);
|
|
66
93
|
const { content, truncated } = truncate(rawContent, cap);
|
|
67
|
-
const title = isHtml ? extractTitle(body) : undefined;
|
|
94
|
+
const title = isHtml ? extractTitle(page.body) : undefined;
|
|
68
95
|
|
|
69
96
|
logger.debug("fetched", {
|
|
70
|
-
url:
|
|
71
|
-
status:
|
|
72
|
-
bytes: body.length,
|
|
97
|
+
url: page.url,
|
|
98
|
+
status: page.statusCode,
|
|
99
|
+
bytes: page.body.length,
|
|
73
100
|
returned: content.length,
|
|
74
101
|
...(truncated ? { truncated: true } : {}),
|
|
75
102
|
});
|
|
76
103
|
|
|
77
104
|
return {
|
|
78
|
-
url:
|
|
79
|
-
status:
|
|
80
|
-
...(contentType ? { contentType } : {}),
|
|
105
|
+
url: page.url,
|
|
106
|
+
status: page.statusCode,
|
|
107
|
+
...(page.contentType ? { contentType: page.contentType } : {}),
|
|
81
108
|
...(title ? { title } : {}),
|
|
82
109
|
content,
|
|
83
110
|
truncated,
|
package/src/html-text.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Turning fetched HTML into the plain text a model can read.
|
|
3
3
|
*
|
|
4
|
-
* Both scraping paths need this:
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Both scraping paths need this: `runWebFetch`'s full-page read and
|
|
5
|
+
* `runScrapeSearch`'s title/snippet extraction. The entity table is shared so
|
|
6
|
+
* the two cannot drift apart.
|
|
7
7
|
*
|
|
8
8
|
* This is deliberately regex-based rather than a DOM parse. The inputs are
|
|
9
9
|
* whole pages fetched for summarization, not documents to be queried, so the
|