@dbx-tools/appkit-web-search 0.9.131 → 0.9.136

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/package.json CHANGED
@@ -27,12 +27,12 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@databricks/appkit": "0.81.0",
30
- "@dbx-tools/appkit": "^0.9.131",
31
- "@dbx-tools/core": "^0.9.131",
32
- "@dbx-tools/model": "^0.9.131",
33
- "@dbx-tools/path": "^0.9.131",
34
- "@dbx-tools/shared-core": "^0.9.131",
35
- "@dbx-tools/shared-model": "^0.9.131",
30
+ "@dbx-tools/appkit": "^0.9.136",
31
+ "@dbx-tools/core": "^0.9.136",
32
+ "@dbx-tools/model": "^0.9.136",
33
+ "@dbx-tools/path": "^0.9.136",
34
+ "@dbx-tools/shared-core": "^0.9.136",
35
+ "@dbx-tools/shared-model": "^0.9.136",
36
36
  "@mastra/core": "1.71.0",
37
37
  "cheerio": "^1.2.0",
38
38
  "entities": "^4.5.0",
@@ -106,7 +106,7 @@
106
106
  "./package.json": "./package.json"
107
107
  }
108
108
  },
109
- "version": "0.9.131",
109
+ "version": "0.9.136",
110
110
  "types": "./lib/index.d.ts",
111
111
  "dbxToolsConfig": {
112
112
  "tags": [
package/src/fetch.ts CHANGED
@@ -20,10 +20,43 @@ import { gotScraping } from "got-scraping";
20
20
  import { assertFetchUrlAllowed } from "./_fetch-url.ts";
21
21
  import { toCallSettings, webFetchExecuteDefaults } from "./defaults.ts";
22
22
  import { decodeHtmlEntities, htmlToText } from "./html-text.ts";
23
- import { executeRead, toWebSearchRuntime, type WebSearchRuntimeInput } from "./runtime.ts";
23
+ import {
24
+ executeRead,
25
+ toWebSearchRuntime,
26
+ type WebSearchRuntime,
27
+ type WebSearchRuntimeInput,
28
+ } from "./runtime.ts";
24
29
  import type { WebFetchRequest, WebFetchResult } from "./schema.ts";
25
30
 
26
31
  const logger = log.logger("web-search/fetch");
32
+ const DEFAULT_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
33
+ const DEFAULT_ASSET_PAGE_MAX_LENGTH = 1_500_000;
34
+
35
+ /** Input for a policy-checked HTML fetch used by in-process asset discovery. */
36
+ export interface WebAssetPageFetchRequest {
37
+ /** Public HTTPS page URL. */
38
+ url: string;
39
+ /** Maximum returned markup length. Defaults to 1.5 million characters. */
40
+ maxLength?: number;
41
+ }
42
+
43
+ /** Input for a policy-checked binary image fetch. */
44
+ export interface WebImageFetchRequest {
45
+ /** Public HTTPS image URL. */
46
+ url: string;
47
+ /** Maximum accepted response size. Defaults to 5 MiB. */
48
+ maxBytes?: number;
49
+ }
50
+
51
+ /** Binary image fetched through the web-search runtime's URL policy. */
52
+ export interface WebImageFetchResult {
53
+ /** Final URL after redirects. */
54
+ url: string;
55
+ /** Image media type returned by the server. */
56
+ contentType: string;
57
+ /** Fetched image bytes. */
58
+ data: Uint8Array;
59
+ }
27
60
 
28
61
  /** The slice of a got-scraping response this module reads. */
29
62
  interface FetchedPage {
@@ -46,38 +79,26 @@ function truncate(text: string, max: number): { content: string; truncated: bool
46
79
  return { content: text.slice(0, max), truncated: true };
47
80
  }
48
81
 
49
- /**
50
- * Fetch a single URL and return its content in the requested format.
51
- *
52
- * Throws when the URL is not permitted by the allow-list (the visible,
53
- * correctable failure the design calls for on the fetch path). The request's
54
- * `maxLength` narrows (never widens) the plugin's `fetchMaxLength` cap.
55
- * `signal` cancels the in-flight fetch.
56
- */
57
- export async function runWebFetch(
58
- request: WebFetchRequest,
59
- runtimeOrConfig: WebSearchRuntimeInput,
82
+ /** Fetch and cache one raw page before caller-specific output reduction. */
83
+ async function fetchPage(
84
+ requestUrl: string,
85
+ runtime: WebSearchRuntime,
60
86
  signal?: AbortSignal,
61
- ): Promise<WebFetchResult> {
62
- const runtime = toWebSearchRuntime(runtimeOrConfig);
87
+ ): Promise<FetchedPage> {
63
88
  const { config } = runtime;
64
- await assertFetchUrlAllowed(request.url, config.allowList);
65
- const cap = Math.min(request.maxLength ?? config.fetchMaxLength, config.fetchMaxLength);
66
-
67
- // The response is cached before the format reduction, so the same page read
68
- // as text and as html shares one network round trip.
89
+ await assertFetchUrlAllowed(requestUrl, config.allowList);
69
90
  const page = await executeRead(
70
91
  runtime,
71
92
  "page-fetch",
72
93
  toCallSettings(webFetchExecuteDefaults, config.timeoutMs, [
73
94
  "web-search",
74
95
  "fetch",
75
- request.url,
96
+ requestUrl,
76
97
  ...config.allowList.patterns,
77
98
  ]),
78
99
  async (executeSignal): Promise<FetchedPage> => {
79
100
  const response = await gotScraping({
80
- url: request.url,
101
+ url: requestUrl,
81
102
  // Bun's node:http2 reports default-port origins with :443 while
82
103
  // http2-wrapper compares them against URL-normalized origins.
83
104
  http2: false,
@@ -96,7 +117,7 @@ export async function runWebFetch(
96
117
  ...(executeSignal ? { signal: executeSignal } : {}),
97
118
  });
98
119
  return {
99
- url: response.url ?? request.url,
120
+ url: response.url ?? requestUrl,
100
121
  statusCode: response.statusCode,
101
122
  contentType: response.headers["content-type"],
102
123
  body: typeof response.body === "string" ? response.body : String(response.body ?? ""),
@@ -105,6 +126,26 @@ export async function runWebFetch(
105
126
  signal,
106
127
  );
107
128
  await assertFetchUrlAllowed(page.url, config.allowList);
129
+ return page;
130
+ }
131
+
132
+ /**
133
+ * Fetch a single URL and return its content in the requested format.
134
+ *
135
+ * Throws when the URL is not permitted by the allow-list (the visible,
136
+ * correctable failure the design calls for on the fetch path). The request's
137
+ * `maxLength` narrows (never widens) the plugin's `fetchMaxLength` cap.
138
+ * `signal` cancels the in-flight fetch.
139
+ */
140
+ export async function runWebFetch(
141
+ request: WebFetchRequest,
142
+ runtimeOrConfig: WebSearchRuntimeInput,
143
+ signal?: AbortSignal,
144
+ ): Promise<WebFetchResult> {
145
+ const runtime = toWebSearchRuntime(runtimeOrConfig);
146
+ const { config } = runtime;
147
+ const cap = Math.min(request.maxLength ?? config.fetchMaxLength, config.fetchMaxLength);
148
+ const page = await fetchPage(request.url, runtime, signal);
108
149
 
109
150
  const isHtml = !page.contentType || /html|xml/i.test(page.contentType);
110
151
  const rawContent = request.format === "html" || !isHtml ? page.body : htmlToText(page.body);
@@ -128,3 +169,91 @@ export async function runWebFetch(
128
169
  truncated,
129
170
  };
130
171
  }
172
+
173
+ /**
174
+ * Fetch bounded raw markup for deterministic in-process asset discovery.
175
+ *
176
+ * This does not widen the model-facing `web_fetch` cap.
177
+ */
178
+ export async function runWebAssetPageFetch(
179
+ request: WebAssetPageFetchRequest,
180
+ runtimeOrConfig: WebSearchRuntimeInput,
181
+ signal?: AbortSignal,
182
+ ): Promise<WebFetchResult> {
183
+ const runtime = toWebSearchRuntime(runtimeOrConfig);
184
+ const cap = Math.min(
185
+ Math.max(1, request.maxLength ?? DEFAULT_ASSET_PAGE_MAX_LENGTH),
186
+ DEFAULT_ASSET_PAGE_MAX_LENGTH,
187
+ );
188
+ const page = await fetchPage(request.url, runtime, signal);
189
+ const { content, truncated } = truncate(page.body, cap);
190
+ const title = extractTitle(page.body);
191
+ return {
192
+ url: page.url,
193
+ status: page.statusCode,
194
+ ...(page.contentType ? { contentType: page.contentType } : {}),
195
+ ...(title ? { title } : {}),
196
+ content,
197
+ truncated,
198
+ };
199
+ }
200
+
201
+ /** Fetch one public image while preserving URL policy, timeout, and redirect checks. */
202
+ export async function runWebImageFetch(
203
+ request: WebImageFetchRequest,
204
+ runtimeOrConfig: WebSearchRuntimeInput,
205
+ signal?: AbortSignal,
206
+ ): Promise<WebImageFetchResult> {
207
+ const runtime = toWebSearchRuntime(runtimeOrConfig);
208
+ const { config } = runtime;
209
+ const maxBytes = Math.max(1, request.maxBytes ?? DEFAULT_IMAGE_MAX_BYTES);
210
+ await assertFetchUrlAllowed(request.url, config.allowList);
211
+ const page = await executeRead(
212
+ runtime,
213
+ "image-fetch",
214
+ {
215
+ default: {
216
+ cache: { enabled: false },
217
+ retry: { enabled: true, attempts: 2 },
218
+ timeout: config.timeoutMs,
219
+ },
220
+ },
221
+ async (executeSignal) => {
222
+ const response = await gotScraping({
223
+ url: request.url,
224
+ responseType: "buffer",
225
+ http2: false,
226
+ timeout: { request: config.timeoutMs },
227
+ throwHttpErrors: true,
228
+ followRedirect: true,
229
+ hooks: {
230
+ beforeRequest: [
231
+ async (options) => {
232
+ await assertFetchUrlAllowed(String(options.url), config.allowList);
233
+ },
234
+ ],
235
+ },
236
+ ...(executeSignal ? { signal: executeSignal } : {}),
237
+ });
238
+ const contentType = String(response.headers["content-type"] ?? "")
239
+ .split(";")[0]!
240
+ .trim()
241
+ .toLowerCase();
242
+ if (!contentType.startsWith("image/")) {
243
+ throw new TypeError(`Expected an image response, received ${contentType || "unknown"}`);
244
+ }
245
+ if (response.body.byteLength > maxBytes) {
246
+ throw new RangeError(`Image response exceeds ${maxBytes} bytes`);
247
+ }
248
+ return {
249
+ url: response.url ?? request.url,
250
+ contentType,
251
+ data: new Uint8Array(response.body),
252
+ };
253
+ },
254
+ signal,
255
+ false,
256
+ );
257
+ await assertFetchUrlAllowed(page.url, config.allowList);
258
+ return page;
259
+ }
package/src/plugin.ts CHANGED
@@ -31,7 +31,14 @@ import {
31
31
  WEB_SEARCH_CONFIG_SCHEMA,
32
32
  type WebSearchPluginConfig,
33
33
  } from "./config.ts";
34
- import { runWebFetch } from "./fetch.ts";
34
+ import {
35
+ runWebAssetPageFetch,
36
+ runWebFetch,
37
+ runWebImageFetch,
38
+ type WebAssetPageFetchRequest,
39
+ type WebImageFetchRequest,
40
+ type WebImageFetchResult,
41
+ } from "./fetch.ts";
35
42
  import { createWebSearchRuntime, type WebSearchRuntime } from "./runtime.ts";
36
43
  import {
37
44
  webFetchRequestSchema,
@@ -230,6 +237,16 @@ export class WebSearchPlugin extends ToolRegistryPlugin<WebSearchPluginConfig> {
230
237
  */
231
238
  fetch: (request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult> =>
232
239
  this.fetch(request, signal),
240
+ /** Fetch expanded HTML for in-process asset discovery. */
241
+ fetchAssetPage: (
242
+ request: WebAssetPageFetchRequest,
243
+ signal?: AbortSignal,
244
+ ): Promise<WebFetchResult> => runWebAssetPageFetch(request, this.runtime, signal),
245
+ /** Fetch one image for in-process visual research. */
246
+ fetchImage: (
247
+ request: WebImageFetchRequest,
248
+ signal?: AbortSignal,
249
+ ): Promise<WebImageFetchResult> => runWebImageFetch(request, this.runtime, signal),
233
250
  };
234
251
  }
235
252
 
package/src/runtime.ts CHANGED
@@ -37,6 +37,10 @@ export interface WebSearchRuntime {
37
37
  execute: WebSearchExecutor;
38
38
  /** Provider-family cooldown deadlines learned from workspace failures. */
39
39
  familyCooldowns: Map<policy.ModelFamily, number>;
40
+ /** Provider families that completed a native search successfully. */
41
+ availableFamilies: Set<policy.ModelFamily>;
42
+ /** First native request currently establishing one family's availability. */
43
+ familyProbes: Map<policy.ModelFamily, Promise<void>>;
40
44
  }
41
45
 
42
46
  /** Runtime-aware operation input, retaining resolved-config compatibility. */
@@ -54,7 +58,13 @@ export function createWebSearchRuntime(
54
58
  overrides?: WebSearchPluginConfig,
55
59
  execute: WebSearchExecutor = directExecute,
56
60
  ): WebSearchRuntime {
57
- return { config: resolveWebSearchConfig(overrides), execute, familyCooldowns: new Map() };
61
+ return {
62
+ config: resolveWebSearchConfig(overrides),
63
+ execute,
64
+ familyCooldowns: new Map(),
65
+ availableFamilies: new Set(),
66
+ familyProbes: new Map(),
67
+ };
58
68
  }
59
69
 
60
70
  /** Build an isolated runtime from config that has already been resolved. */
@@ -62,7 +72,13 @@ export function createResolvedWebSearchRuntime(
62
72
  config: ResolvedWebSearchConfig,
63
73
  execute: WebSearchExecutor = directExecute,
64
74
  ): WebSearchRuntime {
65
- return { config, execute, familyCooldowns: new Map() };
75
+ return {
76
+ config,
77
+ execute,
78
+ familyCooldowns: new Map(),
79
+ availableFamilies: new Set(),
80
+ familyProbes: new Map(),
81
+ };
66
82
  }
67
83
 
68
84
  /** Normalize an explicit runtime or legacy resolved config for one operation. */
@@ -91,10 +107,11 @@ export async function executeRead<T>(
91
107
  settings: WebSearchExecutionSettings,
92
108
  fn: (signal?: AbortSignal) => Promise<T>,
93
109
  signal?: AbortSignal,
110
+ logFailure = true,
94
111
  ): Promise<T> {
95
112
  return pluginExecution.runPluginExecution({
96
113
  plugin: "web-search",
97
- logger,
114
+ logger: logFailure ? logger : { warn() {} },
98
115
  operation,
99
116
  settings,
100
117
  execute: runtime.execute,
package/src/search.ts CHANGED
@@ -231,25 +231,22 @@ async function postServing(
231
231
  const responseBody = await response.text();
232
232
  const model =
233
233
  object.isRecord(body) && typeof body.model === "string" ? body.model : undefined;
234
- logger.error("serving-request-rejected", {
235
- model,
236
- api: url.endsWith("/responses") ? "responses" : "chat",
237
- url,
238
- status: response.status,
239
- responseBody,
240
- });
241
- if (
234
+ const crossRegionDisabled =
242
235
  response.status === 400 &&
243
236
  model &&
244
237
  policy.modelFamily(model) === policy.ModelFamily.Gemini &&
245
- GEMINI_CROSS_REGION_DISABLED.test(responseBody)
246
- ) {
238
+ GEMINI_CROSS_REGION_DISABLED.test(responseBody);
239
+ if (crossRegionDisabled) {
247
240
  const expiresAt = Date.now() + GEMINI_CROSS_REGION_COOLDOWN_MS;
248
241
  runtime.familyCooldowns.set(policy.ModelFamily.Gemini, expiresAt);
249
- logger.warn("provider-family-suppressed", {
250
- family: policy.ModelFamily.Gemini,
251
- reason: "cross-region-disabled",
252
- expiresAt: new Date(expiresAt).toISOString(),
242
+ runtime.availableFamilies.delete(policy.ModelFamily.Gemini);
243
+ } else {
244
+ logger.error("serving-request-rejected", {
245
+ model,
246
+ api: url.endsWith("/responses") ? "responses" : "chat",
247
+ url,
248
+ status: response.status,
249
+ responseBody,
253
250
  });
254
251
  }
255
252
  // Load-shedding and server faults are worth another attempt; a 4xx is
@@ -268,6 +265,7 @@ async function postServing(
268
265
  : response.json();
269
266
  },
270
267
  signal,
268
+ false,
271
269
  );
272
270
  return object.isRecord(payload) ? payload : {};
273
271
  }
@@ -456,29 +454,95 @@ export async function runWebSearch(
456
454
  );
457
455
  }
458
456
 
457
+ return runSelectedWebSearch(request, runtime, ctx, endpoint, signal, writeProgress);
458
+ }
459
+
460
+ /** Run one selected family after its first request establishes availability. */
461
+ async function runSelectedWebSearch(
462
+ request: WebSearchRequest,
463
+ runtime: WebSearchRuntime,
464
+ ctx: WebSearchContext,
465
+ endpoint: ServingEndpointSummary,
466
+ signal?: AbortSignal,
467
+ writeProgress?: ProgressWriter,
468
+ ): Promise<WebSearchResult> {
469
+ const family = endpointFamily(endpoint);
470
+ if (!family || runtime.availableFamilies.has(family)) {
471
+ try {
472
+ return await runNativeWebSearch(request, runtime, ctx, endpoint, signal, writeProgress);
473
+ } catch (error) {
474
+ return runSearchFallback(request, runtime, ctx, endpoint, error, signal, writeProgress);
475
+ }
476
+ }
477
+
478
+ const activeProbe = runtime.familyProbes.get(family);
479
+ if (activeProbe) {
480
+ signal?.throwIfAborted();
481
+ await activeProbe;
482
+ signal?.throwIfAborted();
483
+ return runWebSearch(request, runtime, ctx, signal, writeProgress);
484
+ }
485
+
486
+ let releaseProbe!: () => void;
487
+ const probe = new Promise<void>((resolve) => {
488
+ releaseProbe = resolve;
489
+ });
490
+ runtime.familyProbes.set(family, probe);
491
+
492
+ if (runtime.availableFamilies.has(family) || activeFamilyCooldowns(runtime).has(family)) {
493
+ runtime.familyProbes.delete(family);
494
+ releaseProbe();
495
+ return runWebSearch(request, runtime, ctx, signal, writeProgress);
496
+ }
497
+
498
+ let failure: unknown;
459
499
  try {
460
- return await runNativeWebSearch(request, runtime, ctx, endpoint, signal, writeProgress);
500
+ const result = await runNativeWebSearch(request, runtime, ctx, endpoint, signal, writeProgress);
501
+ runtime.availableFamilies.add(family);
502
+ return result;
461
503
  } catch (error) {
462
- if (
463
- endpointFamily(endpoint) !== policy.ModelFamily.Gemini ||
464
- !activeFamilyCooldowns(runtime).has(policy.ModelFamily.Gemini)
465
- ) {
466
- throw error;
467
- }
468
- const fallback = await resolveWebSearchModel(ctx, runtime, request.model);
469
- if (!fallback) {
470
- if (config.scrapeFallback) {
471
- logger.info("gemini-unavailable:scrape-fallback", { query: request.query });
472
- return runScrapeSearch(request, runtime, signal);
473
- }
474
- throw error;
504
+ failure = error;
505
+ } finally {
506
+ if (runtime.familyProbes.get(family) === probe) runtime.familyProbes.delete(family);
507
+ releaseProbe();
508
+ }
509
+ return runSearchFallback(request, runtime, ctx, endpoint, failure, signal, writeProgress);
510
+ }
511
+
512
+ /** Resolve a cached family failure to another model or the scrape fallback. */
513
+ async function runSearchFallback(
514
+ request: WebSearchRequest,
515
+ runtime: WebSearchRuntime,
516
+ ctx: WebSearchContext,
517
+ endpoint: ServingEndpointSummary,
518
+ error: unknown,
519
+ signal?: AbortSignal,
520
+ writeProgress?: ProgressWriter,
521
+ ): Promise<WebSearchResult> {
522
+ if (
523
+ endpointFamily(endpoint) !== policy.ModelFamily.Gemini ||
524
+ !activeFamilyCooldowns(runtime).has(policy.ModelFamily.Gemini)
525
+ ) {
526
+ throw error;
527
+ }
528
+ const fallback = await resolveWebSearchModel(ctx, runtime, request.model);
529
+ if (!fallback) {
530
+ if (runtime.config.scrapeFallback) {
531
+ logger.warn("web-search-model-fallback", {
532
+ failedModel: endpoint.name,
533
+ fallback: "scrape",
534
+ reason: "cross-region-disabled",
535
+ });
536
+ return runScrapeSearch(request, runtime, signal);
475
537
  }
476
- logger.info("gemini-unavailable:model-fallback", {
477
- failedModel: endpoint.name,
478
- fallbackModel: fallback.name,
479
- });
480
- return runNativeWebSearch(request, runtime, ctx, fallback, signal, writeProgress);
538
+ throw error;
481
539
  }
540
+ logger.warn("web-search-model-fallback", {
541
+ failedModel: endpoint.name,
542
+ fallbackModel: fallback.name,
543
+ reason: "cross-region-disabled",
544
+ });
545
+ return runSelectedWebSearch(request, runtime, ctx, fallback, signal, writeProgress);
482
546
  }
483
547
 
484
548
  /** Execute one native provider request against an already-selected endpoint. */