@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/tool.ts CHANGED
@@ -15,13 +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 { getExecutionContext } from "@databricks/appkit";
22
- import { string } from "@dbx-tools/shared-core";
24
+ import { ValidationError } from "@databricks/appkit";
23
25
  import { createTool } from "@mastra/core/tools";
24
- 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";
25
33
  import { runWebFetch } from "./fetch";
26
34
  import { getWebSearchRuntime } from "./runtime";
27
35
  import {
@@ -29,10 +37,24 @@ import {
29
37
  webFetchResultSchema,
30
38
  webSearchRequestSchema,
31
39
  webSearchResultSchema,
32
- type WebFetchRequest,
33
- type WebSearchRequest,
40
+ WEB_FETCH_TOOL_DESCRIPTION,
41
+ WEB_SEARCH_TOOL_DESCRIPTION,
34
42
  } from "./schema";
35
- import { runWebSearch, type WebSearchContext } from "./search";
43
+ import { resolveWebSearchContext, runWebSearch } from "./search";
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
+ }
36
58
 
37
59
  /** Options shared by both web tools. */
38
60
  export interface WebSearchToolOptions {
@@ -43,24 +65,14 @@ export interface WebSearchToolOptions {
43
65
  * gates every call; a URL-pattern (or list) gates only matching calls;
44
66
  * omit / `false` for no approval. See {@link ApprovalGate}.
45
67
  */
46
- approval?: ApprovalGate;
68
+ approval?: ApprovalGate | ApprovalPolicy;
47
69
  }
48
70
 
49
71
  /** Resolve the effective gate: explicit tool option, else the plugin default. */
50
- function effectiveGate(opts: WebSearchToolOptions): ApprovalGate {
51
- return opts.approval ?? getWebSearchRuntime().config.approval;
52
- }
53
-
54
- /**
55
- * Resolve the OBO workspace client + host from the active AppKit execution
56
- * context. Runs inside `agent.stream`'s `asUser(req)` scope, so the search
57
- * hits the serving endpoint as the requesting user; outside a user context it
58
- * falls back to the service principal.
59
- */
60
- async function webSearchContext(): Promise<WebSearchContext> {
61
- const ctx = getExecutionContext();
62
- const host = (await ctx.client.config.getHost()).toString();
63
- return { client: ctx.client, host };
72
+ function effectiveGate(opts: WebSearchToolOptions): ApprovalPolicy {
73
+ return opts.approval === undefined
74
+ ? getWebSearchRuntime().config.approval
75
+ : toApprovalPolicy(opts.approval);
64
76
  }
65
77
 
66
78
  /**
@@ -82,24 +94,16 @@ export function webSearchTool(opts: WebSearchToolOptions = {}) {
82
94
  const gate = effectiveGate(opts);
83
95
  return createTool({
84
96
  id: opts.id ?? "web_search",
85
- description: string.toDescription(`
86
- Search the web for current information and get an answer synthesized from
87
- live results, with the sources it used. Pass a natural-language query;
88
- the search runs inside a web-search-capable model (chosen independently
89
- of your own model). Optionally pass a model name to use a specific
90
- web-search model. Use it whenever a question needs up-to-date or external
91
- information you don't already have.
92
- `),
97
+ description: WEB_SEARCH_TOOL_DESCRIPTION,
93
98
  inputSchema: webSearchRequestSchema,
94
99
  outputSchema: webSearchResultSchema,
95
100
  // A search's result URLs aren't known before the call, so a pattern gate
96
- // is treated as "always gate"; boolean gates pass through.
97
- ...(gate === false || gate === undefined
98
- ? {}
99
- : { requireApproval: () => (typeof gate === "boolean" ? gate : true) }),
101
+ // is treated as "always gate".
102
+ ...(gate.mode === "none" ? {} : { requireApproval: () => true }),
100
103
  execute: async (input) => {
101
104
  const { config } = getWebSearchRuntime();
102
- return runWebSearch(input as WebSearchRequest, config, await webSearchContext());
105
+ const request = parseToolInput(webSearchRequestSchema, input);
106
+ return runWebSearch(request, config, await resolveWebSearchContext());
103
107
  },
104
108
  });
105
109
  }
@@ -119,26 +123,21 @@ export function webFetchTool(opts: WebSearchToolOptions = {}) {
119
123
  const gate = effectiveGate(opts);
120
124
  return createTool({
121
125
  id: opts.id ?? "web_fetch",
122
- description: string.toDescription(`
123
- Fetch a single web page and return its readable contents. Pass an
124
- absolute URL (including https://); set format to "html" for raw markup
125
- instead of extracted text. Use it to read a page returned by web_search
126
- or provided by the user. Content is length-capped; fetching a URL outside
127
- the configured allow-list is refused.
128
- `),
126
+ description: WEB_FETCH_TOOL_DESCRIPTION,
129
127
  inputSchema: webFetchRequestSchema,
130
128
  outputSchema: webFetchResultSchema,
131
129
  // A fetch knows its single target URL, so a pattern gate is evaluated
132
- // precisely against it; boolean gates pass through.
133
- ...(gate === false || gate === undefined
130
+ // precisely against it.
131
+ ...(gate.mode === "none"
134
132
  ? {}
135
133
  : {
136
134
  requireApproval: (input: unknown) =>
137
- approvalMatches(gate, [(input as WebFetchRequest).url]),
135
+ gate.mode === "always" ||
136
+ approvalMatches(gate, [parseToolInput(webFetchRequestSchema, input).url]),
138
137
  }),
139
138
  execute: async (input) => {
140
139
  const { config } = getWebSearchRuntime();
141
- return runWebFetch(input as WebFetchRequest, config);
140
+ return runWebFetch(parseToolInput(webFetchRequestSchema, input), config);
142
141
  },
143
142
  });
144
143
  }
@@ -6,8 +6,8 @@ import {
6
6
  parseAllowedUrls,
7
7
  toUrlAllowList,
8
8
  } from "../src/allowlist";
9
- import { approvalMatches, resolveWebSearchConfig } from "../src/config";
10
- import { htmlToText } from "../src/fetch";
9
+ import { approvalMatches, resolveWebSearchConfig, toApprovalPolicy } from "../src/config";
10
+ import { htmlToText } from "../src/html-text";
11
11
  import {
12
12
  detectWebSearchProvider,
13
13
  supportsWebSearch,
@@ -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", () => {