@dbx-tools/appkit-web-search 0.3.29 → 0.3.31

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/search.ts CHANGED
@@ -10,23 +10,31 @@
10
10
  * agent's chat model (the agent may run on a model without web search).
11
11
  * A pinned `model` (request or config) is fuzzy-matched; otherwise the
12
12
  * configured fallback order (Gemini, then GPT) is walked to the first
13
- * web-search-capable endpoint that exists in the workspace. An explicit
14
- * but unsupported model is a hard error, not a silent fallback.
13
+ * web-search-capable endpoint that exists. An explicit but unsupported
14
+ * model is a hard error, not a silent fallback.
15
15
  * 2. POSTs to the provider's REST surface (`/serving-endpoints/responses`
16
16
  * for OpenAI, `/serving-endpoints/chat/completions` for Gemini) with the
17
- * mapped tool spec, using the OBO-scoped workspace client.
17
+ * mapped tool spec, authenticated as the OBO caller.
18
18
  * 3. Returns the synthesized answer plus the cited sources, with citations
19
19
  * silently filtered through the configured URL allow-list.
20
20
  *
21
21
  * @module
22
22
  */
23
23
 
24
- import { getExecutionContext } from "@databricks/appkit";
24
+ import {
25
+ ConfigurationError,
26
+ ConnectionError,
27
+ ExecutionError,
28
+ getExecutionContext,
29
+ ValidationError,
30
+ } from "@databricks/appkit";
25
31
  import { invoke, resolve, serving } from "@dbx-tools/model";
26
- import { error, log, string } from "@dbx-tools/shared-core";
32
+ import { log, object, string } from "@dbx-tools/shared-core";
27
33
  import { openaiChat, openaiResponses } from "@dbx-tools/shared-model";
28
- import type { ResolvedWebSearchConfig } from "./config";
34
+ import { MODEL_ENV, SERVING_ENDPOINT_ENV, type ResolvedWebSearchConfig } from "./config";
35
+ import { toCallSettings, webSearchExecuteDefaults } from "./defaults";
29
36
  import { detectWebSearchProvider, supportsWebSearch, webSearchToolSpec } from "./provider";
37
+ import { executeRead } from "./runtime";
30
38
  import type { WebSearchCitation, WebSearchRequest, WebSearchResult } from "./schema";
31
39
  import { runScrapeSearch } from "./scrape";
32
40
 
@@ -35,6 +43,20 @@ const logger = log.logger("web-search/search");
35
43
  const { resolveModel } = resolve;
36
44
  const { listServingEndpoints } = serving;
37
45
 
46
+ /**
47
+ * How deep the grounding-metadata walk descends. Gemini nests its sources a
48
+ * handful of levels down and the shape varies by model version, so the walk
49
+ * is generic; the bound is what keeps a pathological payload from turning it
50
+ * into a full traversal of the response.
51
+ */
52
+ const MAX_GROUNDING_WALK_DEPTH = 6;
53
+
54
+ /** Lowest HTTP status treated as a server-side (retryable) serving failure. */
55
+ const SERVER_ERROR_STATUS = 500;
56
+
57
+ /** Status Model Serving uses to shed load; retryable like a 5xx. */
58
+ const RATE_LIMITED_STATUS = 429;
59
+
38
60
  /** Context a search needs from the caller: the OBO client + workspace host. */
39
61
  export interface WebSearchContext {
40
62
  client: WorkspaceClientLike;
@@ -64,9 +86,13 @@ export async function resolveWebSearchContext(): Promise<WebSearchContext> {
64
86
  *
65
87
  * Returns the chosen endpoint id, or `null` when the workspace has no
66
88
  * web-search-capable model deployed (the caller then uses the scrape
67
- * fallback). An explicit request that resolves to an unsupported / absent
68
- * model throws, so a deliberate bad pin surfaces rather than silently
69
- * degrading.
89
+ * fallback). A pin the caller chose deliberately - the request's `model`, the
90
+ * plugin's `model`, or `WEB_SEARCH_MODEL` - throws when it resolves to an
91
+ * unsupported / absent endpoint, so a bad pin surfaces rather than silently
92
+ * degrading. A pin inherited from the shared `DATABRICKS_SERVING_ENDPOINT_NAME`
93
+ * binding is only a preference: that endpoint is the app's serving endpoint,
94
+ * not necessarily a web-search-capable one, so an unusable value falls through
95
+ * to the fallback order.
70
96
  */
71
97
  async function resolveWebSearchModel(
72
98
  ctx: WebSearchContext,
@@ -77,6 +103,8 @@ async function resolveWebSearchModel(
77
103
  // Only deployed, web-search-capable endpoints are candidates.
78
104
  const capable = endpoints.filter((e) => supportsWebSearch(e.name));
79
105
  const pinned = requested ?? config.model;
106
+ const pinIsDeliberate =
107
+ requested !== undefined || config.modelSource === "config" || config.modelSource === MODEL_ENV;
80
108
 
81
109
  if (pinned) {
82
110
  // Resolve the explicit ask within the capable set only.
@@ -87,19 +115,33 @@ async function resolveWebSearchModel(
87
115
  });
88
116
  // resolveModel returns the input verbatim on no match; require it to be a
89
117
  // real capable endpoint so a bad pin is a clear error, not a phantom call.
90
- if (!capable.some((e) => e.name === modelId) || !supportsWebSearch(modelId)) {
91
- throw new Error(
92
- `web-search: requested model "${pinned}" is not a deployed web-search-capable endpoint. ` +
93
- `Deployed GPT/Gemini endpoints: [${capable.map((e) => e.name).join(", ") || "none"}].`,
118
+ const usable = capable.some((e) => e.name === modelId) && supportsWebSearch(modelId);
119
+ if (usable) return modelId;
120
+ const deployed = capable.map((e) => e.name).join(", ") || "none";
121
+ if (!pinIsDeliberate) {
122
+ logger.info("shared-endpoint-not-web-capable", {
123
+ envVar: SERVING_ENDPOINT_ENV,
124
+ deployed,
125
+ });
126
+ } else if (requested !== undefined) {
127
+ throw ValidationError.invalidValue(
128
+ "model",
129
+ pinned,
130
+ `a deployed web-search-capable endpoint (deployed: ${deployed})`,
131
+ );
132
+ } else {
133
+ throw ConfigurationError.resourceNotFound(
134
+ "Web-search-capable serving endpoint",
135
+ `Deployed web-search-capable endpoints: ${deployed}. Set model or ${MODEL_ENV} to one of them.`,
94
136
  );
95
137
  }
96
- return modelId;
97
138
  }
98
139
 
99
140
  if (capable.length === 0) return null;
100
141
 
101
- // Nothing pinned: prefer the configured fallback order (Gemini, then GPT)
102
- // when those ids are actually deployed; else take the best capable endpoint.
142
+ // Nothing usable pinned: prefer the configured fallback order (Gemini, then
143
+ // GPT) when those ids are actually deployed; else take the best capable
144
+ // endpoint.
103
145
  const { modelId } = resolveModel(capable, {
104
146
  fallbacks: config.modelFallbacks,
105
147
  fuzzy: config.fuzzy,
@@ -108,20 +150,57 @@ async function resolveWebSearchModel(
108
150
  return capable.some((e) => e.name === modelId) ? modelId : (capable[0]?.name ?? null);
109
151
  }
110
152
 
111
- /** POST a serving request through the OBO client and return the parsed JSON. */
153
+ /**
154
+ * POST a serving request as the OBO caller and return the parsed JSON body.
155
+ *
156
+ * Auth headers are minted per call from the OBO client's SDK config, which
157
+ * refreshes the token when it is close to expiry, so the request carries the
158
+ * requesting user's identity.
159
+ *
160
+ * This is the one Databricks call in the repo that does not go through
161
+ * `apiClient.request` + `databricks.toContext` (which does forward
162
+ * cancellation). Retry classification here has to distinguish a load-shed or
163
+ * server fault from this request's own 4xx, and `fetch` exposes the HTTP status
164
+ * directly; the SDK raises an `ApiError` that carries the status under
165
+ * inconsistent keys, which is guesswork by comparison.
166
+ */
112
167
  async function postServing(
113
168
  ctx: WebSearchContext,
114
- path: string,
169
+ url: string,
115
170
  body: unknown,
171
+ config: ResolvedWebSearchConfig,
172
+ cacheKey: readonly (string | number)[],
173
+ signal?: AbortSignal,
116
174
  ): Promise<Record<string, unknown>> {
117
- const res = await ctx.client.apiClient.request({
118
- path,
119
- method: "POST",
120
- headers: new Headers({ Accept: "application/json", "Content-Type": "application/json" }),
121
- raw: false,
122
- payload: body,
123
- });
124
- return (res ?? {}) as Record<string, unknown>;
175
+ const payload = await executeRead(
176
+ "serving-request",
177
+ toCallSettings(webSearchExecuteDefaults, config.timeoutMs, cacheKey),
178
+ async (executeSignal): Promise<unknown> => {
179
+ const response = await fetch(url, {
180
+ method: "POST",
181
+ headers: {
182
+ ...(await invoke.authHeaders(ctx.client)),
183
+ Accept: "application/json",
184
+ "Content-Type": "application/json",
185
+ },
186
+ body: JSON.stringify(body),
187
+ ...(executeSignal ? { signal: executeSignal } : {}),
188
+ });
189
+ if (!response.ok) {
190
+ // Load-shedding and server faults are worth another attempt; a 4xx is
191
+ // this request's own problem, so it must not be retried.
192
+ const retryable =
193
+ response.status >= SERVER_ERROR_STATUS || response.status === RATE_LIMITED_STATUS;
194
+ const message = `web-search: Model Serving rejected the search request (HTTP ${response.status})`;
195
+ throw retryable
196
+ ? new ConnectionError(message, { context: { status: response.status } })
197
+ : new ExecutionError(message, { context: { status: response.status } });
198
+ }
199
+ return response.json();
200
+ },
201
+ signal,
202
+ );
203
+ return object.isRecord(payload) ? payload : {};
125
204
  }
126
205
 
127
206
  /* --------------------------- response extraction --------------------------- */
@@ -149,22 +228,22 @@ function fromChatPayload(payload: Record<string, unknown>): {
149
228
  answer: string;
150
229
  citations: WebSearchCitation[];
151
230
  } {
152
- const choices = Array.isArray(payload.choices) ? (payload.choices as unknown[]) : [];
153
- const message = (choices[0] as { message?: Record<string, unknown> })?.message ?? {};
231
+ const choices = Array.isArray(payload.choices) ? payload.choices : [];
232
+ const first = choices[0];
233
+ const message = object.isRecord(first) && object.isRecord(first.message) ? first.message : {};
154
234
  const answer = openaiChat.chatContentToText(message.content);
155
235
  const citations: WebSearchCitation[] = [];
156
236
  // Best-effort grounding extraction: walk any nested object for {uri|url,title}.
157
237
  const seen = new Set<string>();
158
238
  const visit = (v: unknown, depth: number): void => {
159
- if (depth > 6 || v === null || typeof v !== "object") return;
160
- const o = v as Record<string, unknown>;
161
- const url = string.trimToEmpty(o.url) || string.trimToEmpty(o.uri);
239
+ if (depth > MAX_GROUNDING_WALK_DEPTH || !object.isRecord(v)) return;
240
+ const url = string.trimToEmpty(v.url) || string.trimToEmpty(v.uri);
162
241
  if (url && !seen.has(url)) {
163
242
  seen.add(url);
164
- const title = string.trimToEmpty(o.title);
243
+ const title = string.trimToEmpty(v.title);
165
244
  citations.push({ url, ...(title ? { title } : {}) });
166
245
  }
167
- for (const val of Object.values(o)) {
246
+ for (const val of Object.values(v)) {
168
247
  if (Array.isArray(val)) val.forEach((x) => visit(x, depth + 1));
169
248
  else if (val && typeof val === "object") visit(val, depth + 1);
170
249
  }
@@ -179,11 +258,14 @@ function fromChatPayload(payload: Record<string, unknown>): {
179
258
  * workspace has no such endpoint AND the scrape fallback is enabled, falls
180
259
  * back to a DuckDuckGo scrape so the tool still returns results instead of
181
260
  * erroring. Citations are filtered through the configured URL allow-list.
261
+ *
262
+ * `signal` cancels the whole call, including the in-flight serving request.
182
263
  */
183
264
  export async function runWebSearch(
184
265
  request: WebSearchRequest,
185
266
  config: ResolvedWebSearchConfig,
186
267
  ctx: WebSearchContext,
268
+ signal?: AbortSignal,
187
269
  ): Promise<WebSearchResult> {
188
270
  const modelId = await resolveWebSearchModel(ctx, config, request.model);
189
271
 
@@ -191,20 +273,21 @@ export async function runWebSearch(
191
273
  // No native web-search model deployed in this workspace.
192
274
  if (config.scrapeFallback) {
193
275
  logger.info("no-native-model:scrape-fallback", { query: request.query });
194
- return runScrapeSearch(request, config);
276
+ return runScrapeSearch(request, config, signal);
195
277
  }
196
- throw new Error(
197
- "web-search: no web-search-capable model (GPT/Gemini) is deployed in this workspace, " +
198
- "and the scrape fallback is disabled. Deploy a supported endpoint, set `model` / " +
199
- "WEB_SEARCH_MODEL, or enable the fallback (WEB_SEARCH_SCRAPE_FALLBACK=1).",
278
+ throw ConfigurationError.resourceNotFound(
279
+ "Web-search-capable serving endpoint",
280
+ "No GPT/Gemini endpoint is deployed in this workspace and the scrape fallback is " +
281
+ `disabled. Deploy a supported endpoint, set model or ${MODEL_ENV}, or enable the ` +
282
+ "fallback (WEB_SEARCH_SCRAPE_FALLBACK=1).",
200
283
  );
201
284
  }
202
285
 
203
286
  const provider = detectWebSearchProvider(modelId)!; // guaranteed by resolve step
204
287
  const spec = webSearchToolSpec(provider, config.webSearchTools);
205
288
 
206
- const path =
207
- spec.api === "responses" ? `/${invoke.RESPONSES_PATH}` : `/${invoke.CHAT_COMPLETIONS_PATH}`;
289
+ const url =
290
+ spec.api === "responses" ? invoke.responsesUrl(ctx.host) : invoke.chatCompletionsUrl(ctx.host);
208
291
  const body =
209
292
  spec.api === "responses"
210
293
  ? {
@@ -218,13 +301,14 @@ export async function runWebSearch(
218
301
  tools: [spec.tool],
219
302
  };
220
303
 
221
- let payload: Record<string, unknown>;
222
- try {
223
- payload = await postServing(ctx, path, body);
224
- } catch (err) {
225
- logger.warn("serving-error", { model: modelId, provider, error: error.errorMessage(err) });
226
- throw err;
227
- }
304
+ const payload = await postServing(
305
+ ctx,
306
+ url,
307
+ body,
308
+ config,
309
+ ["web-search", "serving", spec.api, modelId, request.query],
310
+ signal,
311
+ );
228
312
 
229
313
  const { answer, citations } =
230
314
  spec.api === "responses" ? fromResponsesPayload(payload) : fromChatPayload(payload);
package/src/tool.ts CHANGED
@@ -15,12 +15,21 @@
15
15
  * before the call) treats a pattern gate as "always gate". `approval` falls
16
16
  * back to the plugin's `approval` config when a tool omits its own.
17
17
  *
18
+ * The same two tools are exposed to AppKit's own agents through the plugin's
19
+ * `ToolProvider` (see `plugin.ts`); this module is the Mastra half.
20
+ *
18
21
  * @module
19
22
  */
20
23
 
21
- import { string } from "@dbx-tools/shared-core";
24
+ import { ValidationError } from "@databricks/appkit";
22
25
  import { createTool } from "@mastra/core/tools";
23
- import { approvalMatches, type ApprovalGate } from "./config";
26
+ import type { z } from "zod";
27
+ import {
28
+ approvalMatches,
29
+ toApprovalPolicy,
30
+ type ApprovalGate,
31
+ type ApprovalPolicy,
32
+ } from "./config";
24
33
  import { runWebFetch } from "./fetch";
25
34
  import { getWebSearchRuntime } from "./runtime";
26
35
  import {
@@ -28,11 +37,25 @@ import {
28
37
  webFetchResultSchema,
29
38
  webSearchRequestSchema,
30
39
  webSearchResultSchema,
31
- type WebFetchRequest,
32
- type WebSearchRequest,
40
+ WEB_FETCH_TOOL_DESCRIPTION,
41
+ WEB_SEARCH_TOOL_DESCRIPTION,
33
42
  } from "./schema";
34
43
  import { resolveWebSearchContext, runWebSearch } from "./search";
35
44
 
45
+ /**
46
+ * Validate a tool call's arguments at the runtime boundary. Mastra checks the
47
+ * input schema before dispatch, but the argument still arrives typed as
48
+ * `unknown` and the model is the one filling it in. The rejected value is not
49
+ * echoed back.
50
+ */
51
+ function parseToolInput<S extends z.ZodType>(schema: S, input: unknown): z.infer<S> {
52
+ const parsed = schema.safeParse(input);
53
+ if (!parsed.success) {
54
+ throw ValidationError.invalidValue("input", input, "arguments matching the tool's schema");
55
+ }
56
+ return parsed.data;
57
+ }
58
+
36
59
  /** Options shared by both web tools. */
37
60
  export interface WebSearchToolOptions {
38
61
  /** Override the tool id. */
@@ -42,12 +65,14 @@ export interface WebSearchToolOptions {
42
65
  * gates every call; a URL-pattern (or list) gates only matching calls;
43
66
  * omit / `false` for no approval. See {@link ApprovalGate}.
44
67
  */
45
- approval?: ApprovalGate;
68
+ approval?: ApprovalGate | ApprovalPolicy;
46
69
  }
47
70
 
48
71
  /** Resolve the effective gate: explicit tool option, else the plugin default. */
49
- function effectiveGate(opts: WebSearchToolOptions): ApprovalGate {
50
- return opts.approval ?? getWebSearchRuntime().config.approval;
72
+ function effectiveGate(opts: WebSearchToolOptions): ApprovalPolicy {
73
+ return opts.approval === undefined
74
+ ? getWebSearchRuntime().config.approval
75
+ : toApprovalPolicy(opts.approval);
51
76
  }
52
77
 
53
78
  /**
@@ -69,24 +94,16 @@ export function webSearchTool(opts: WebSearchToolOptions = {}) {
69
94
  const gate = effectiveGate(opts);
70
95
  return createTool({
71
96
  id: opts.id ?? "web_search",
72
- description: string.toDescription(`
73
- Search the web for current information and get an answer synthesized from
74
- live results, with the sources it used. Pass a natural-language query;
75
- the search runs inside a web-search-capable model (chosen independently
76
- of your own model). Optionally pass a model name to use a specific
77
- web-search model. Use it whenever a question needs up-to-date or external
78
- information you don't already have.
79
- `),
97
+ description: WEB_SEARCH_TOOL_DESCRIPTION,
80
98
  inputSchema: webSearchRequestSchema,
81
99
  outputSchema: webSearchResultSchema,
82
100
  // A search's result URLs aren't known before the call, so a pattern gate
83
- // is treated as "always gate"; boolean gates pass through.
84
- ...(gate === false || gate === undefined
85
- ? {}
86
- : { requireApproval: () => (typeof gate === "boolean" ? gate : true) }),
101
+ // is treated as "always gate".
102
+ ...(gate.mode === "none" ? {} : { requireApproval: () => true }),
87
103
  execute: async (input) => {
88
104
  const { config } = getWebSearchRuntime();
89
- return runWebSearch(input as WebSearchRequest, config, await resolveWebSearchContext());
105
+ const request = parseToolInput(webSearchRequestSchema, input);
106
+ return runWebSearch(request, config, await resolveWebSearchContext());
90
107
  },
91
108
  });
92
109
  }
@@ -106,26 +123,21 @@ export function webFetchTool(opts: WebSearchToolOptions = {}) {
106
123
  const gate = effectiveGate(opts);
107
124
  return createTool({
108
125
  id: opts.id ?? "web_fetch",
109
- description: string.toDescription(`
110
- Fetch a single web page and return its readable contents. Pass an
111
- absolute URL (including https://); set format to "html" for raw markup
112
- instead of extracted text. Use it to read a page returned by web_search
113
- or provided by the user. Content is length-capped; fetching a URL outside
114
- the configured allow-list is refused.
115
- `),
126
+ description: WEB_FETCH_TOOL_DESCRIPTION,
116
127
  inputSchema: webFetchRequestSchema,
117
128
  outputSchema: webFetchResultSchema,
118
129
  // A fetch knows its single target URL, so a pattern gate is evaluated
119
- // precisely against it; boolean gates pass through.
120
- ...(gate === false || gate === undefined
130
+ // precisely against it.
131
+ ...(gate.mode === "none"
121
132
  ? {}
122
133
  : {
123
134
  requireApproval: (input: unknown) =>
124
- approvalMatches(gate, [(input as WebFetchRequest).url]),
135
+ gate.mode === "always" ||
136
+ approvalMatches(gate, [parseToolInput(webFetchRequestSchema, input).url]),
125
137
  }),
126
138
  execute: async (input) => {
127
139
  const { config } = getWebSearchRuntime();
128
- return runWebFetch(input as WebFetchRequest, config);
140
+ return runWebFetch(parseToolInput(webFetchRequestSchema, input), config);
129
141
  },
130
142
  });
131
143
  }
@@ -6,7 +6,7 @@ import {
6
6
  parseAllowedUrls,
7
7
  toUrlAllowList,
8
8
  } from "../src/allowlist";
9
- import { approvalMatches, resolveWebSearchConfig } from "../src/config";
9
+ import { approvalMatches, resolveWebSearchConfig, toApprovalPolicy } from "../src/config";
10
10
  import { htmlToText } from "../src/html-text";
11
11
  import {
12
12
  detectWebSearchProvider,
@@ -54,7 +54,7 @@ describe("web-search allow-list", () => {
54
54
 
55
55
  it("assertUrlAllowed throws for a disallowed URL, passes an allowed one", () => {
56
56
  const list = toUrlAllowList(parseAllowedUrls(["databricks.com"]));
57
- assert.throws(() => assertUrlAllowed("https://evil.example.com/", list), /not permitted/);
57
+ assert.throws(() => assertUrlAllowed("https://evil.example.com/", list), /allow-list/);
58
58
  assert.doesNotThrow(() => assertUrlAllowed("https://databricks.com/", list));
59
59
  });
60
60
  });
@@ -97,8 +97,9 @@ describe("web-search provider detection", () => {
97
97
  describe("web-search config", () => {
98
98
  it("defaults model fallbacks to Gemini-then-GPT and approval to none", () => {
99
99
  const c = resolveWebSearchConfig();
100
- assert.equal(c.approval, false);
100
+ assert.deepEqual(c.approval, { mode: "none" });
101
101
  assert.equal(c.model, undefined);
102
+ assert.equal(c.modelSource, "none");
102
103
  assert.match(c.modelFallbacks[0]!, /gemini/);
103
104
  assert.ok(c.modelFallbacks.some((m) => m.includes("gpt")));
104
105
  });
@@ -112,6 +113,71 @@ describe("web-search config", () => {
112
113
  assert.equal(resolveWebSearchConfig().scrapeFallback, true);
113
114
  assert.equal(resolveWebSearchConfig({ scrapeFallback: false }).scrapeFallback, false);
114
115
  });
116
+
117
+ it("records where a pinned endpoint came from", () => {
118
+ assert.equal(resolveWebSearchConfig({ model: "gemini" }).modelSource, "config");
119
+ process.env.DATABRICKS_SERVING_ENDPOINT_NAME = "databricks-claude-sonnet-4-6";
120
+ try {
121
+ const c = resolveWebSearchConfig();
122
+ assert.equal(c.model, "databricks-claude-sonnet-4-6");
123
+ assert.equal(c.modelSource, "DATABRICKS_SERVING_ENDPOINT_NAME");
124
+ // The dedicated override wins over the shared resource binding.
125
+ assert.equal(resolveWebSearchConfig({ model: "gemini" }).modelSource, "config");
126
+ } finally {
127
+ delete process.env.DATABRICKS_SERVING_ENDPOINT_NAME;
128
+ }
129
+ });
130
+
131
+ it("names the URL policy, defaulting from whether an allow-list was given", () => {
132
+ assert.equal(resolveWebSearchConfig().urlPolicy, "unrestricted");
133
+ assert.equal(resolveWebSearchConfig().allowList.restricted, false);
134
+ const restricted = resolveWebSearchConfig({ allowedUrls: ["databricks.com"] });
135
+ assert.equal(restricted.urlPolicy, "allowlist");
136
+ assert.equal(restricted.allowList.restricted, true);
137
+ assert.equal(
138
+ resolveWebSearchConfig({ urlPolicy: "allowlist", allowedUrls: ["databricks.com"] }).urlPolicy,
139
+ "allowlist",
140
+ );
141
+ });
142
+
143
+ it("fails loudly on a URL policy that contradicts its allow-list", () => {
144
+ assert.throws(() => resolveWebSearchConfig({ urlPolicy: "allowlist" }), /allowedUrls/);
145
+ assert.throws(
146
+ () => resolveWebSearchConfig({ urlPolicy: "unrestricted", allowedUrls: ["databricks.com"] }),
147
+ /allowedUrls/,
148
+ );
149
+ });
150
+
151
+ it("fails loudly on a WEB_SEARCH_TOOLS value that is not a JSON object", () => {
152
+ process.env.WEB_SEARCH_TOOLS = "not-json";
153
+ try {
154
+ assert.throws(() => resolveWebSearchConfig(), /WEB_SEARCH_TOOLS/);
155
+ } finally {
156
+ delete process.env.WEB_SEARCH_TOOLS;
157
+ }
158
+ });
159
+
160
+ it("rejects a webSearchTools override that is not a tool spec", () => {
161
+ assert.throws(() => webSearchToolSpec("gemini", { gemini: "nope" }), /webSearchTools.gemini/);
162
+ });
163
+ });
164
+
165
+ describe("web-search approval policy", () => {
166
+ it("normalizes every accepted spelling", () => {
167
+ assert.deepEqual(toApprovalPolicy(undefined), { mode: "none" });
168
+ assert.deepEqual(toApprovalPolicy(false), { mode: "none" });
169
+ assert.deepEqual(toApprovalPolicy(true), { mode: "always" });
170
+ assert.deepEqual(toApprovalPolicy("*.internal.example.com"), {
171
+ mode: "urls",
172
+ patterns: ["*.internal.example.com"],
173
+ });
174
+ assert.deepEqual(toApprovalPolicy({ mode: "always" }), { mode: "always" });
175
+ });
176
+
177
+ it("collapses an empty pattern list to no approval", () => {
178
+ assert.deepEqual(toApprovalPolicy(" "), { mode: "none" });
179
+ assert.deepEqual(toApprovalPolicy({ mode: "urls", patterns: [] }), { mode: "none" });
180
+ });
115
181
  });
116
182
 
117
183
  describe("web-search approval gate", () => {