@dbx-tools/appkit-web-search 0.3.28 → 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/fetch.ts CHANGED
@@ -19,50 +19,28 @@ import { log } from "@dbx-tools/shared-core";
19
19
  import { gotScraping } from "got-scraping";
20
20
  import { assertUrlAllowed } from "./allowlist";
21
21
  import type { ResolvedWebSearchConfig } from "./config";
22
+ import { toCallSettings, webFetchExecuteDefaults } from "./defaults";
23
+ import { decodeHtmlEntities, htmlToText } from "./html-text";
24
+ import { executeRead } from "./runtime";
22
25
  import type { WebFetchRequest, WebFetchResult } from "./schema";
23
26
 
24
27
  const logger = log.logger("web-search/fetch");
25
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
+
26
37
  /** Pull the <title> text out of an HTML document, when present. */
27
38
  function extractTitle(html: string): string | undefined {
28
39
  const match = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html);
29
- const title = match?.[1] ? decodeEntities(match[1]).trim() : "";
40
+ const title = match?.[1] ? decodeHtmlEntities(match[1]).trim() : "";
30
41
  return title.length > 0 ? title : undefined;
31
42
  }
32
43
 
33
- /** Decode the handful of HTML entities that survive tag-stripping. */
34
- function decodeEntities(text: string): string {
35
- return text
36
- .replace(/&nbsp;/g, " ")
37
- .replace(/&amp;/g, "&")
38
- .replace(/&lt;/g, "<")
39
- .replace(/&gt;/g, ">")
40
- .replace(/&quot;/g, '"')
41
- .replace(/&#39;/g, "'")
42
- .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)));
43
- }
44
-
45
- /**
46
- * Reduce an HTML document to readable plain text: drop `<script>` /
47
- * `<style>` / `<noscript>` blocks and HTML comments, turn block-level tags
48
- * into newlines, strip the remaining tags, decode entities, and collapse
49
- * runs of blank lines / trailing spaces.
50
- */
51
- export function htmlToText(html: string): string {
52
- return decodeEntities(
53
- html
54
- .replace(/<!--[\s\S]*?-->/g, "")
55
- .replace(/<(script|style|noscript)[^>]*>[\s\S]*?<\/\1>/gi, "")
56
- .replace(/<\/(p|div|section|article|li|tr|h[1-6]|header|footer|br)>/gi, "\n")
57
- .replace(/<br\s*\/?>/gi, "\n")
58
- .replace(/<[^>]+>/g, ""),
59
- )
60
- .replace(/[ \t]+\n/g, "\n")
61
- .replace(/\n{3,}/g, "\n\n")
62
- .replace(/[ \t]{2,}/g, " ")
63
- .trim();
64
- }
65
-
66
44
  /** Truncate `text` to `max` chars, reporting whether it was cut. */
67
45
  function truncate(text: string, max: number): { content: string; truncated: boolean } {
68
46
  if (text.length <= max) return { content: text, truncated: false };
@@ -73,43 +51,60 @@ function truncate(text: string, max: number): { content: string; truncated: bool
73
51
  * Fetch a single URL and return its content in the requested format.
74
52
  *
75
53
  * Throws when the URL is not permitted by the allow-list (the visible,
76
- * correctable failure the design calls for on the fetch path). Network /
77
- * HTTP errors propagate from got-scraping. The request's `maxLength` narrows
78
- * (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.
79
57
  */
80
58
  export async function runWebFetch(
81
59
  request: WebFetchRequest,
82
60
  config: ResolvedWebSearchConfig,
61
+ signal?: AbortSignal,
83
62
  ): Promise<WebFetchResult> {
84
63
  assertUrlAllowed(request.url, config.allowList);
85
64
  const cap = Math.min(request.maxLength ?? config.fetchMaxLength, config.fetchMaxLength);
86
65
 
87
- const response = await gotScraping({
88
- url: request.url,
89
- timeout: { request: config.timeoutMs },
90
- throwHttpErrors: false,
91
- followRedirect: true,
92
- });
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
+ );
93
90
 
94
- const body = typeof response.body === "string" ? response.body : String(response.body ?? "");
95
- const contentType = response.headers["content-type"];
96
- const isHtml = !contentType || /html|xml/i.test(contentType);
97
- 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);
98
93
  const { content, truncated } = truncate(rawContent, cap);
99
- const title = isHtml ? extractTitle(body) : undefined;
94
+ const title = isHtml ? extractTitle(page.body) : undefined;
100
95
 
101
96
  logger.debug("fetched", {
102
- url: response.url,
103
- status: response.statusCode,
104
- bytes: body.length,
97
+ url: page.url,
98
+ status: page.statusCode,
99
+ bytes: page.body.length,
105
100
  returned: content.length,
106
101
  ...(truncated ? { truncated: true } : {}),
107
102
  });
108
103
 
109
104
  return {
110
- url: response.url ?? request.url,
111
- status: response.statusCode,
112
- ...(contentType ? { contentType } : {}),
105
+ url: page.url,
106
+ status: page.statusCode,
107
+ ...(page.contentType ? { contentType: page.contentType } : {}),
113
108
  ...(title ? { title } : {}),
114
109
  content,
115
110
  truncated,
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Turning fetched HTML into the plain text a model can read.
3
+ *
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
+ *
8
+ * This is deliberately regex-based rather than a DOM parse. The inputs are
9
+ * whole pages fetched for summarization, not documents to be queried, so the
10
+ * cost of a real parser buys nothing - and a malformed page still degrades to
11
+ * readable text instead of throwing.
12
+ *
13
+ * @module
14
+ */
15
+
16
+ /**
17
+ * The named entities that survive tag-stripping in practice, plus numeric
18
+ * escapes. Not a complete HTML entity table - anything rarer passes through
19
+ * as-is, which reads acceptably in model input.
20
+ */
21
+ const NAMED_ENTITIES: Record<string, string> = {
22
+ "&nbsp;": " ",
23
+ "&amp;": "&",
24
+ "&lt;": "<",
25
+ "&gt;": ">",
26
+ "&quot;": '"',
27
+ "&#39;": "'",
28
+ "&#x27;": "'",
29
+ "&apos;": "'",
30
+ };
31
+
32
+ const NAMED_ENTITY_REGEXP = new RegExp(Object.keys(NAMED_ENTITIES).join("|"), "g");
33
+ const NUMERIC_ENTITY_REGEXP = /&#(\d+);/g;
34
+ const TAG_REGEXP = /<[^>]+>/g;
35
+
36
+ /** Decode the HTML entities that survive tag-stripping. */
37
+ export function decodeHtmlEntities(text: string): string {
38
+ return text
39
+ .replace(NAMED_ENTITY_REGEXP, (entity) => NAMED_ENTITIES[entity] ?? entity)
40
+ .replace(NUMERIC_ENTITY_REGEXP, (_, code) => String.fromCodePoint(Number(code)));
41
+ }
42
+
43
+ /**
44
+ * Strip tags and decode entities from a short HTML fragment, collapsing all
45
+ * whitespace to single spaces. For inline snippets (a search result title or
46
+ * summary), where layout carries no meaning.
47
+ */
48
+ export function htmlFragmentToText(html: string): string {
49
+ return decodeHtmlEntities(html.replace(TAG_REGEXP, "")).replace(/\s+/g, " ").trim();
50
+ }
51
+
52
+ /**
53
+ * Reduce a full HTML document to readable plain text: drop `<script>` /
54
+ * `<style>` / `<noscript>` blocks and comments, turn block-level tags into
55
+ * newlines, strip the remaining tags, decode entities, and collapse runs of
56
+ * blank lines / trailing spaces. Unlike {@link htmlFragmentToText}, this
57
+ * preserves line structure because paragraph breaks carry meaning in a page.
58
+ */
59
+ export function htmlToText(html: string): string {
60
+ return decodeHtmlEntities(
61
+ html
62
+ .replace(/<!--[\s\S]*?-->/g, "")
63
+ .replace(/<(script|style|noscript)[^>]*>[\s\S]*?<\/\1>/gi, "")
64
+ .replace(/<\/(p|div|section|article|li|tr|h[1-6]|header|footer|br)>/gi, "\n")
65
+ .replace(/<br\s*\/?>/gi, "\n")
66
+ .replace(TAG_REGEXP, ""),
67
+ )
68
+ .replace(/[ \t]+\n/g, "\n")
69
+ .replace(/\n{3,}/g, "\n\n")
70
+ .replace(/[ \t]{2,}/g, " ")
71
+ .trim();
72
+ }
package/src/plugin.ts CHANGED
@@ -1,68 +1,218 @@
1
1
  /**
2
2
  * AppKit plugin (registered name: `web-search`) that owns the resolved
3
- * web-search runtime - the URL allow-list, result / length caps, timeout,
4
- * and default approval gate the {@link webSearchTool} / {@link webFetchTool}
5
- * read. Registering it resolves and logs the effective config (whether an
6
- * allow-list is active, the caps) so a misconfiguration is visible in the
7
- * boot logs rather than on the first search.
3
+ * web-search runtime - the URL policy, result / length caps, timeout, and
4
+ * default approval gate the {@link webSearchTool} / {@link webFetchTool}
5
+ * read. Registering it resolves and logs the effective config (which URL
6
+ * policy is in force, the caps) so a misconfiguration is visible in the boot
7
+ * logs rather than on the first search, and installs the plugin's
8
+ * `execute()` as the runtime's executor so every outbound call picks up
9
+ * AppKit's cache / retry / timeout / telemetry chain.
8
10
  *
9
- * The tools do the actual work when spread into an agent; this plugin primes
10
- * the shared runtime they reuse and exposes direct {@link runWebSearch} /
11
- * {@link runWebFetch} for non-agent callers.
11
+ * The plugin also implements AppKit's `ToolProvider`, so an AppKit agent gets
12
+ * `web_search` / `web_fetch` without going through Mastra; the Mastra tools in
13
+ * `tool.ts` are the other half and share the same runtime.
12
14
  *
13
15
  * @module
14
16
  */
15
17
 
16
- import { getExecutionContext, Plugin, toPlugin, type PluginManifest } from "@databricks/appkit";
17
- import { log } from "@dbx-tools/shared-core";
18
- import { WEB_SEARCH_CONFIG_SCHEMA, type WebSearchPluginConfig } from "./config";
18
+ import {
19
+ Plugin,
20
+ ResourceType,
21
+ toPlugin,
22
+ type PluginManifest,
23
+ type ResourceRequirement,
24
+ } from "@databricks/appkit";
25
+ import {
26
+ defineTool,
27
+ executeFromRegistry,
28
+ toolsFromRegistry,
29
+ type AgentToolDefinition,
30
+ type ToolProvider,
31
+ type ToolRegistry,
32
+ } from "@databricks/appkit/beta";
33
+ import { log, string } from "@dbx-tools/shared-core";
34
+ import {
35
+ MODEL_ENV,
36
+ SERVING_ENDPOINT_ENV,
37
+ WEB_SEARCH_CONFIG_SCHEMA,
38
+ type WebSearchPluginConfig,
39
+ } from "./config";
19
40
  import { runWebFetch } from "./fetch";
20
- import { getWebSearchRuntime } from "./runtime";
41
+ import { getWebSearchRuntime, resetWebSearchRuntime, setWebSearchExecutor } from "./runtime";
42
+ import {
43
+ webFetchRequestSchema,
44
+ webSearchRequestSchema,
45
+ WEB_FETCH_TOOL_DESCRIPTION,
46
+ WEB_SEARCH_TOOL_DESCRIPTION,
47
+ } from "./schema";
21
48
  import type { WebFetchRequest, WebFetchResult, WebSearchRequest, WebSearchResult } from "./schema";
22
- import { runWebSearch, type WebSearchContext } from "./search";
49
+ import { resolveWebSearchContext, runWebSearch } from "./search";
50
+
51
+ const logger = log.logger("web-search");
52
+
53
+ /**
54
+ * The Model Serving endpoint the native web-search tool runs on. Declared
55
+ * optional because the plugin resolves a web-search-capable endpoint from the
56
+ * live catalogue when nothing is pinned; {@link WebSearchPlugin.getResourceRequirements}
57
+ * promotes it to required once a deployment names one. `CAN_QUERY` is the
58
+ * weakest permission that can invoke an endpoint.
59
+ */
60
+ const SERVING_ENDPOINT_RESOURCE = {
61
+ type: ResourceType.SERVING_ENDPOINT,
62
+ alias: "Web Search Endpoint",
63
+ resourceKey: "web-search-endpoint",
64
+ description:
65
+ "Model Serving endpoint running the native web-search tool. Optional: resolved from the " +
66
+ "workspace catalogue (Gemini, then GPT) when no endpoint is pinned.",
67
+ permission: "CAN_QUERY",
68
+ fields: {
69
+ name: {
70
+ env: SERVING_ENDPOINT_ENV,
71
+ description: `Serving endpoint name for web search. ${MODEL_ENV} overrides it.`,
72
+ discovery: {
73
+ type: "cli",
74
+ cliCommand: "databricks serving-endpoints list --profile <PROFILE> --output json",
75
+ selectField: ".name",
76
+ },
77
+ },
78
+ },
79
+ } satisfies Omit<ResourceRequirement, "required">;
23
80
 
24
81
  /**
25
82
  * AppKit plugin that resolves and holds the web-search runtime config used
26
83
  * by the `web_search` / `web_fetch` tools.
84
+ *
85
+ * @example
86
+ * ```ts
87
+ * import { createApp, server } from "@databricks/appkit";
88
+ * import { plugin as webSearchPlugin } from "@dbx-tools/appkit-web-search";
89
+ *
90
+ * await createApp({
91
+ * plugins: [
92
+ * server(),
93
+ * webSearchPlugin.webSearch({
94
+ * model: "gemini",
95
+ * urlPolicy: "allowlist",
96
+ * allowedUrls: ["*.databricks.com"],
97
+ * }),
98
+ * ],
99
+ * });
100
+ * ```
27
101
  */
28
- export class WebSearchPlugin extends Plugin<WebSearchPluginConfig> {
102
+ export class WebSearchPlugin extends Plugin<WebSearchPluginConfig> implements ToolProvider {
29
103
  static manifest = {
30
104
  name: "web-search",
31
105
  displayName: "Web Search",
32
106
  description:
33
- "Searches the web (via duck-duck-scrape) and fetches pages (via got-scraping), " +
34
- "with an optional URL allow-list and per-tool approval gating.",
107
+ "Searches the web through the Databricks native web-search tool on its own " +
108
+ "web-capable serving endpoint, and fetches pages via got-scraping, behind an " +
109
+ "optional URL allow-list and per-tool approval gating.",
35
110
  stability: "beta",
36
111
  resources: {
37
112
  required: [],
38
- optional: [],
113
+ optional: [SERVING_ENDPOINT_RESOURCE],
39
114
  },
40
115
  config: { schema: WEB_SEARCH_CONFIG_SCHEMA },
41
116
  } satisfies PluginManifest<"web-search">;
42
117
 
43
- private logger = log.logger(this);
118
+ /**
119
+ * Promote the serving endpoint to a required resource once a deployment
120
+ * pins one, through plugin config or either environment name. Left optional
121
+ * otherwise, because the plugin picks a web-search-capable endpoint out of
122
+ * the live catalogue on its own.
123
+ */
124
+ static getResourceRequirements(config: WebSearchPluginConfig): ResourceRequirement[] {
125
+ const pinned =
126
+ string.trimToNull(config.model) ??
127
+ string.trimToNull(process.env[MODEL_ENV]) ??
128
+ string.trimToNull(process.env[SERVING_ENDPOINT_ENV]);
129
+ return pinned === null ? [] : [{ ...SERVING_ENDPOINT_RESOURCE, required: true }];
130
+ }
131
+
132
+ /**
133
+ * The tools this plugin offers to an AppKit agent. Both are reads and both
134
+ * run under the caller's identity.
135
+ *
136
+ * Neither is `autoInheritable`. `web_fetch` reaches a URL the model chose,
137
+ * which is reachable from inside the workspace network, so it must only
138
+ * appear in an agent that asked for it and accepted the URL policy that
139
+ * comes with it. `web_search` is safer but still spends serving tokens on
140
+ * every call, so it is opt-in for cost rather than for safety.
141
+ *
142
+ * `execute` re-parses its arguments with the local schema: AppKit validates
143
+ * against the same schema first, but re-parsing is what gives the body typed
144
+ * arguments instead of `unknown`.
145
+ */
146
+ private readonly tools: ToolRegistry = {
147
+ web_search: defineTool({
148
+ description: WEB_SEARCH_TOOL_DESCRIPTION,
149
+ schema: webSearchRequestSchema,
150
+ annotations: { effect: "read", requiresUserContext: true },
151
+ autoInheritable: false,
152
+ execute: async (args, signal) => this.search(webSearchRequestSchema.parse(args), signal),
153
+ }),
154
+ web_fetch: defineTool({
155
+ description: WEB_FETCH_TOOL_DESCRIPTION,
156
+ schema: webFetchRequestSchema,
157
+ annotations: { effect: "read", requiresUserContext: true },
158
+ autoInheritable: false,
159
+ execute: async (args, signal) => this.fetch(webFetchRequestSchema.parse(args), signal),
160
+ }),
161
+ };
44
162
 
45
163
  /**
46
- * Prime the shared runtime from this plugin's config (over env) and log
47
- * the effective policy so an active allow-list / caps are obvious at boot.
164
+ * Prime the shared runtime from this plugin's config (over env), route the
165
+ * tools' outbound calls through this plugin's interceptor chain, and log the
166
+ * effective policy so an active allow-list / caps are obvious at boot.
48
167
  */
49
168
  override async setup(): Promise<void> {
50
169
  const { config } = getWebSearchRuntime(this.config);
51
- this.logger.info("ready", {
170
+ setWebSearchExecutor((fn, settings) => this.execute(fn, settings));
171
+ logger.info("ready", {
52
172
  model: config.model ?? `fallbacks:[${config.modelFallbacks.join(", ")}]`,
53
- restricted: config.allowList.restricted,
173
+ modelSource: config.modelSource,
174
+ urlPolicy: config.urlPolicy,
54
175
  ...(config.allowList.restricted ? { allowedUrls: config.allowList.patterns } : {}),
55
176
  maxCitations: config.maxCitations,
56
177
  fetchMaxLength: config.fetchMaxLength,
57
- approval: config.approval === false ? "none" : config.approval,
178
+ approval: config.approval.mode,
179
+ scrapeFallback: config.scrapeFallback,
58
180
  });
59
181
  }
60
182
 
61
- /** Resolve the OBO client + host from the active execution context. */
62
- private async searchContext(): Promise<WebSearchContext> {
63
- const ctx = getExecutionContext();
64
- const host = (await ctx.client.config.getHost()).toString();
65
- return { client: ctx.client, host };
183
+ /**
184
+ * Drop the shared runtime so a restarted app re-resolves config and does not
185
+ * keep calling through a torn-down plugin's `execute()`. Bounded and
186
+ * idempotent: there is no connection to drain, only the memo to clear.
187
+ */
188
+ async shutdown(): Promise<void> {
189
+ resetWebSearchRuntime();
190
+ }
191
+
192
+ /**
193
+ * Abort in-flight work. AppKit's graceful shutdown only invokes this hook -
194
+ * it never calls {@link shutdown} - so the runtime memo is dropped from here
195
+ * to keep a restarted app from calling through a torn-down `execute()`. The
196
+ * teardown is synchronous and idempotent, so the un-awaited call costs
197
+ * nothing.
198
+ */
199
+ override abortActiveOperations(): void {
200
+ super.abortActiveOperations();
201
+ void this.shutdown();
202
+ }
203
+
204
+ /** AppKit `ToolProvider`: the tool definitions offered to an agent. */
205
+ getAgentTools(): AgentToolDefinition[] {
206
+ return toolsFromRegistry(this.tools);
207
+ }
208
+
209
+ /**
210
+ * AppKit `ToolProvider`: run one tool call. Arguments are validated against
211
+ * the tool's schema first, and a validation failure comes back as an
212
+ * LLM-friendly string so the model can correct itself on the next turn.
213
+ */
214
+ async executeAgentTool(name: string, args: unknown, signal?: AbortSignal): Promise<unknown> {
215
+ return executeFromRegistry(this.tools, name, args, signal);
66
216
  }
67
217
 
68
218
  override exports() {
@@ -72,16 +222,52 @@ export class WebSearchPlugin extends Plugin<WebSearchPluginConfig> {
72
222
  * OBO client from the active execution context and reads the shared
73
223
  * runtime config primed at setup.
74
224
  */
75
- search: async (request: WebSearchRequest): Promise<WebSearchResult> =>
76
- runWebSearch(request, getWebSearchRuntime().config, await this.searchContext()),
225
+ search: (request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> =>
226
+ this.search(request, signal),
77
227
  /**
78
228
  * Fetch one URL directly (bypassing the agent tool). Enforces the
79
- * configured allow-list. Reads the shared runtime config.
229
+ * configured URL policy. Reads the shared runtime config.
80
230
  */
81
- fetch: (request: WebFetchRequest): Promise<WebFetchResult> =>
82
- runWebFetch(request, getWebSearchRuntime().config),
231
+ fetch: (request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult> =>
232
+ this.fetch(request, signal),
83
233
  };
84
234
  }
235
+
236
+ private async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
237
+ return runWebSearch(
238
+ request,
239
+ getWebSearchRuntime().config,
240
+ await resolveWebSearchContext(),
241
+ signal,
242
+ );
243
+ }
244
+
245
+ private async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult> {
246
+ return runWebFetch(request, getWebSearchRuntime().config, signal);
247
+ }
85
248
  }
86
249
 
250
+ /**
251
+ * Register the web-search runtime with AppKit.
252
+ *
253
+ * @example
254
+ * ```ts
255
+ * import { createApp, server } from "@databricks/appkit";
256
+ * import { plugin as webSearchPlugin, tool as webTool } from "@dbx-tools/appkit-web-search";
257
+ * import { agents, plugin as mastraPlugin } from "@dbx-tools/appkit-mastra";
258
+ *
259
+ * const researcher = agents.createAgent({
260
+ * instructions: "Research questions with web_search, then read sources with web_fetch.",
261
+ * tools: () => ({ web_search: webTool.webSearchTool(), web_fetch: webTool.webFetchTool() }),
262
+ * });
263
+ *
264
+ * await createApp({
265
+ * plugins: [
266
+ * server(),
267
+ * webSearchPlugin.webSearch({ model: "gemini" }),
268
+ * mastraPlugin.mastra({ agents: researcher }),
269
+ * ],
270
+ * });
271
+ * ```
272
+ */
87
273
  export const webSearch = toPlugin(WebSearchPlugin);
package/src/provider.ts CHANGED
@@ -23,6 +23,9 @@
23
23
  * @module
24
24
  */
25
25
 
26
+ import { ValidationError } from "@databricks/appkit";
27
+ import { z } from "zod";
28
+
26
29
  /** A web-search-capable model provider family. */
27
30
  export type WebSearchProvider = "openai" | "gemini";
28
31
 
@@ -70,21 +73,41 @@ export function supportsWebSearch(modelId: string): boolean {
70
73
  return detectWebSearchProvider(modelId) !== null;
71
74
  }
72
75
 
76
+ /**
77
+ * Runtime shape of one entry in the operator override map. The map arrives as
78
+ * parsed JSON (config or `WEB_SEARCH_TOOLS`), so it is validated rather than
79
+ * asserted.
80
+ */
81
+ const providerOverrideSchema = z.object({
82
+ api: z.enum(["responses", "chat"]).optional(),
83
+ tool: z.record(z.string(), z.unknown()).optional(),
84
+ });
85
+
73
86
  /**
74
87
  * Resolve the effective {@link WebSearchProviderSpec} for a provider: the
75
88
  * built-in default, with any operator override (the `webSearchTools` map,
76
89
  * keyed by provider) shallow-merged over it. An override may replace just the
77
- * `tool` (the common case - a new tool type) or also the `api`.
90
+ * `tool` (the common case - a new tool type) or also the `api`. An override
91
+ * that is not one of those two fields is a deployment mistake that would
92
+ * otherwise be silently dropped, so it throws.
78
93
  */
79
94
  export function webSearchToolSpec(
80
95
  provider: WebSearchProvider,
81
96
  overrides?: Record<string, unknown>,
82
97
  ): WebSearchProviderSpec {
83
98
  const base = WEB_SEARCH_PROVIDERS[provider];
84
- const override = overrides?.[provider] as Partial<WebSearchProviderSpec> | undefined;
85
- if (!override) return base;
99
+ const raw = overrides?.[provider];
100
+ if (raw === undefined) return base;
101
+ const parsed = providerOverrideSchema.safeParse(raw);
102
+ if (!parsed.success) {
103
+ throw ValidationError.invalidValue(
104
+ `webSearchTools.${provider}`,
105
+ raw,
106
+ 'an object with an optional "api" ("responses" | "chat") and an optional "tool" object',
107
+ );
108
+ }
86
109
  return {
87
- api: override.api ?? base.api,
88
- tool: override.tool ?? base.tool,
110
+ api: parsed.data.api ?? base.api,
111
+ tool: parsed.data.tool ?? base.tool,
89
112
  };
90
113
  }