@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/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
- * Resolution never throws here; it just fills defaults. Precedence per field:
17
- * explicit plugin config wins, then the matching environment variable, then a
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
- * Env fallbacks: `WEB_SEARCH_MODEL`, `WEB_SEARCH_MODEL_FALLBACKS`,
21
- * `WEB_SEARCH_TOOLS` (JSON), `WEB_SEARCH_ALLOWED_URLS`,
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`, `WEB_SEARCH_FUZZY`, `WEB_SEARCH_FUZZY_THRESHOLD`.
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 { BasePluginConfig } from "@databricks/appkit";
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 the {@link modelFallbacks} order. Chosen
73
- * independently of the calling agent's chat model.
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. Omit for no approval.
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
- /** Compiled allow-list (permit-all when unconfigured). */
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 gate. */
160
- approval: ApprovalGate;
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
- /** Parse the `WEB_SEARCH_TOOLS` env var (JSON), else `{}`. Bad JSON is ignored. */
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
- return json.parseRecord(process.env.WEB_SEARCH_TOOLS) ?? {};
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}. Never throws - the model is resolved
219
- * lazily at call time (against the live catalogue), so an unconfigured plugin
220
- * resolves to sensible defaults with no restrictions.
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 model = config.model ?? process.env.WEB_SEARCH_MODEL;
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
- allowList: toUrlAllowList(patterns),
255
- approval: config.approval ?? false,
435
+ urlPolicy,
436
+ allowList: toUrlAllowList(urlPolicy === "allowlist" ? patterns : []),
437
+ approval: toApprovalPolicy(config.approval),
256
438
  };
257
439
  }
258
440
 
259
441
  /**
260
- * Resolve an {@link ApprovalGate} against a set of candidate URLs into a
261
- * concrete boolean: `true`/`false` pass through; a pattern (or list) gates
262
- * when ANY candidate matches. Empty candidates with a pattern gate never
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(gate: ApprovalGate, urls: readonly string[]): boolean {
267
- if (typeof gate === "boolean") return gate;
268
- const patterns = parseAllowedUrls(typeof gate === "string" ? gate : [...gate]);
269
- if (patterns.length === 0) return false;
270
- const list = toUrlAllowList(patterns);
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
  }
@@ -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). Network /
45
- * HTTP errors propagate from got-scraping. The request's `maxLength` narrows
46
- * (never widens) the plugin's `fetchMaxLength` cap.
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
- const response = await gotScraping({
56
- url: request.url,
57
- timeout: { request: config.timeoutMs },
58
- throwHttpErrors: false,
59
- followRedirect: true,
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 body = typeof response.body === "string" ? response.body : String(response.body ?? "");
63
- const contentType = response.headers["content-type"];
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: response.url,
71
- status: response.statusCode,
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: response.url ?? request.url,
79
- status: response.statusCode,
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: {@link fetchUrl}'s full-page read and the
5
- * DuckDuckGo fallback's title/snippet extraction. They previously carried
6
- * separate entity tables that drifted, so the decode lives here once.
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