@dbx-tools/appkit-web-search 0.3.21

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/README.md ADDED
@@ -0,0 +1,234 @@
1
+ # @dbx-tools/appkit-web-search
2
+
3
+ Server-side web-search runtime, Mastra tools, and AppKit plugin.
4
+
5
+ Import this package when an AppKit or Mastra backend needs to search the web and
6
+ read pages. `web_search` runs on the
7
+ [Databricks Model Serving native web-search tool](https://docs.databricks.com/aws/en/machine-learning/model-serving/web-search):
8
+ the model searches the web server-side and returns a synthesized answer plus the
9
+ sources it used - no third-party search API key. `web_fetch` reads a single page
10
+ through [`got-scraping`](https://www.npmjs.com/package/got-scraping) (browser-like
11
+ TLS + header fingerprints so a fetch survives common bot walls), which Databricks
12
+ has no equivalent for.
13
+
14
+ Key features:
15
+
16
+ - AppKit plugin registration that resolves and logs the effective policy at boot.
17
+ - Two Mastra tools: `web_search` (answer + citations) and `web_fetch` (page
18
+ contents as readable text or raw HTML).
19
+ - `web_search` resolves its OWN web-search-capable model - defaulting to Gemini,
20
+ then GPT - independently of the calling agent's chat model (which may not
21
+ support web search). Loose names (`"gemini"`, `"gpt"`) fuzzy-match the live
22
+ catalogue via [`@dbx-tools/model`](../model).
23
+ - A built-in provider -> tool-spec map (OpenAI Responses API
24
+ `{"type":"web_search"}`, Gemini Chat Completions `{"google_search":{}}`),
25
+ overridable per provider via the `WEB_SEARCH_TOOLS` setting.
26
+ - An optional URL allow-list (globs or bare hosts) built on
27
+ [`@dbx-tools/path`](../path)'s `match` matcher: `web_search` silently filters
28
+ disallowed citations, `web_fetch` refuses a disallowed URL.
29
+ - Per-tool approval gating: gate every call, or (for `web_fetch`) only calls
30
+ whose URL matches a pattern, mapped onto Mastra's `requireApproval`.
31
+
32
+ ## Why Use This Over Native AppKit
33
+
34
+ AppKit has no first-party web-search or page-fetch surface. Use this package when
35
+ an agent needs to look things up on the open web or read a URL the user pasted,
36
+ with a policy layer (allow-list + optional approval) around it so a deployment
37
+ controls which sites are reachable and which calls pause for a human. Crucially,
38
+ the web-search tool resolves its own web-search-capable model, so an agent
39
+ running on any chat model (including one without web search) can still search. It
40
+ is a thin add-on in the same shape as [`@dbx-tools/email`](../email): a Mastra
41
+ tool pair plus an AppKit plugin that primes their shared runtime.
42
+
43
+ ## Register The AppKit Plugin
44
+
45
+ ```ts
46
+ import { createApp, lakebase, server } from "@databricks/appkit";
47
+ import { plugin as webSearchPlugin, tool as webTool } from "@dbx-tools/appkit-web-search";
48
+ import { agents, plugin as mastraPlugin } from "@dbx-tools/appkit-mastra";
49
+
50
+ const researcher = agents.createAgent({
51
+ instructions: "Research questions using web_search, then read sources with web_fetch.",
52
+ tools: () => ({
53
+ web_search: webTool.webSearchTool(),
54
+ web_fetch: webTool.webFetchTool(),
55
+ }),
56
+ });
57
+
58
+ await createApp({
59
+ plugins: [
60
+ server(),
61
+ lakebase(),
62
+ webSearchPlugin.webSearch({
63
+ model: "gemini", // defaults to Gemini, then GPT, when omitted
64
+ allowedUrls: ["*.databricks.com", "docs.example.com"],
65
+ }),
66
+ mastraPlugin.mastra({ agents: researcher, storage: true }),
67
+ ],
68
+ });
69
+ ```
70
+
71
+ `plugin.webSearch()` resolves config (over env), compiles the allow-list, and
72
+ primes the shared runtime the tools reuse. `tool.webSearchTool()` /
73
+ `tool.webFetchTool()` build the two Mastra tools. Approval, when enabled,
74
+ requires Mastra storage, so register `lakebase()` or configure storage in the
75
+ Mastra plugin.
76
+
77
+ ## Choose The Web-Search Model
78
+
79
+ The native web-search tool only runs on certain models, and it is provider-
80
+ specific. This package resolves a web-search-capable model INDEPENDENTLY of the
81
+ agent's chat model, so an agent on any model can still search:
82
+
83
+ - Default preference: an appropriate **Gemini**, then **GPT** (`modelFallbacks`).
84
+ - Pin one via `model` (or `WEB_SEARCH_MODEL`): an endpoint name
85
+ (`"databricks-gemini-3-pro"`), a loose name (`"gemini"`, `"gpt"`), or a
86
+ capability class - all fuzzy-matched against the live catalogue.
87
+ - Per call, the model can pass a `model` argument to override.
88
+ - If an explicitly requested model doesn't support web search (e.g. a Claude or
89
+ Llama endpoint), the tool errors rather than silently searching with the wrong
90
+ thing. When nothing is pinned, unsupported fallbacks are skipped.
91
+
92
+ Model resolution runs against the LIVE workspace catalogue (via
93
+ [`@dbx-tools/model`](../model)), so it only ever picks an endpoint that is
94
+ actually deployed - a configured id that doesn't exist is never called.
95
+
96
+ ### Scrape Fallback
97
+
98
+ The native tool is always preferred. But when the workspace has NO deployed
99
+ GPT/Gemini endpoint (so native web search can't run at all), the tool falls
100
+ back to a DuckDuckGo scrape (via `got-scraping`) so it still returns results
101
+ instead of erroring. Fallback results set `model` to `"scrape:duckduckgo"` and
102
+ carry the substance in `citations` (there is no model synthesizing an answer,
103
+ so `answer` is a lead-in over the top results). It is enabled by default; set
104
+ `scrapeFallback: false` (or `WEB_SEARCH_SCRAPE_FALLBACK=0`) to require a native
105
+ model and error when none is deployed.
106
+
107
+ The right provider tool-spec is selected automatically from the resolved model:
108
+ OpenAI GPT uses the Responses API `{"type":"web_search"}`; Gemini uses Chat
109
+ Completions `{"google_search":{}}`. Override or extend that map per provider with
110
+ the `webSearchTools` setting (env `WEB_SEARCH_TOOLS`, JSON) as the platform
111
+ evolves:
112
+
113
+ ```ts
114
+ plugin.webSearch({
115
+ model: "databricks-gemini-3-pro",
116
+ webSearchTools: { gemini: { tool: { google_search: {} } } },
117
+ });
118
+ ```
119
+
120
+ ## Search And Fetch Without An Agent
121
+
122
+ ```ts
123
+ import { search, fetch, runtime } from "@dbx-tools/appkit-web-search";
124
+ import { getExecutionContext } from "@databricks/appkit";
125
+
126
+ // Prime the shared runtime once (or let the plugin do it at setup):
127
+ runtime.getWebSearchRuntime({ model: "gemini", allowedUrls: ["*.databricks.com"] });
128
+
129
+ // web_search needs the OBO client + host from the active execution context:
130
+ const ctx = getExecutionContext();
131
+ const host = (await ctx.client.config.getHost()).toString();
132
+
133
+ const result = await search.runWebSearch(
134
+ { query: "unity catalog lineage best practices" },
135
+ runtime.getWebSearchRuntime().config,
136
+ { client: ctx.client, host },
137
+ );
138
+ console.log(result.answer, result.citations, result.model);
139
+
140
+ const page = await fetch.runWebFetch(
141
+ { url: result.citations[0]!.url, format: "text" },
142
+ runtime.getWebSearchRuntime().config,
143
+ );
144
+ ```
145
+
146
+ Use direct calls for operational lookups, tests, or admin flows where a model is
147
+ not involved. The same resolved runtime is used by the AppKit plugin and tools.
148
+
149
+ ## Restrict Which URLs Are Reachable
150
+
151
+ ```ts
152
+ import { allowlist } from "@dbx-tools/appkit-web-search";
153
+
154
+ const list = allowlist.toUrlAllowList(
155
+ allowlist.parseAllowedUrls(["*.databricks.com", "docs.example.com/api/**"]),
156
+ );
157
+
158
+ list.allows("https://docs.databricks.com/aws/en/index.html"); // true
159
+ list.allows("https://evil.example.com/"); // false
160
+ ```
161
+
162
+ Each entry is a glob compiled by [`@dbx-tools/path`](../path)'s `match` matcher.
163
+ A host entry (no path, e.g. `databricks.com` or `*.databricks.com`) matches the
164
+ URL's hostname, and a bare host also matches its subdomains; a path entry
165
+ (`docs.example.com/api/**`) matches host + pathname. Enforcement is asymmetric by
166
+ design: `web_search` citations are silently filtered to the permitted set (the
167
+ model never surfaces a source it then can't fetch), while an explicit `web_fetch`
168
+ of a disallowed URL is refused with an error (a visible, correctable mistake). An
169
+ empty / absent allow-list permits everything.
170
+
171
+ ## Gate Calls For Approval
172
+
173
+ Both tools run without approval by default. Pass `approval` to a tool (or set it
174
+ plugin-wide) to require a human click. `true` gates every call; a URL pattern (or
175
+ list) gates only calls whose URL matches - a `web_fetch` is evaluated precisely
176
+ against its target URL.
177
+
178
+ ```ts
179
+ import { tool } from "@dbx-tools/appkit-web-search";
180
+
181
+ // Every fetch requires approval:
182
+ tool.webFetchTool({ approval: true });
183
+
184
+ // Only fetches of an internal domain require approval:
185
+ tool.webFetchTool({ approval: "*.internal.example.com" });
186
+
187
+ // Plugin-wide default (tools inherit unless they set their own):
188
+ plugin.webSearch({ approval: ["*.internal.example.com", "*.corp.example.com"] });
189
+ ```
190
+
191
+ Approval uses the same glob syntax as the allow-list, so the two policies read
192
+ identically.
193
+
194
+ ## Configuration
195
+
196
+ Resolution order is explicit config first, then env vars, then a built-in
197
+ default:
198
+
199
+ - `WEB_SEARCH_MODEL` (default web-search model; else the Gemini->GPT fallbacks);
200
+ - `WEB_SEARCH_MODEL_FALLBACKS` (comma/space-separated candidate ids);
201
+ - `WEB_SEARCH_TOOLS` (JSON provider -> tool-spec override map);
202
+ - `WEB_SEARCH_FUZZY` / `WEB_SEARCH_FUZZY_THRESHOLD` (loose-name matching; default on / 0.4);
203
+ - `WEB_SEARCH_MAX_CITATIONS` (default 10);
204
+ - `WEB_SEARCH_FETCH_MAX_LENGTH` (default 50000 characters);
205
+ - `WEB_SEARCH_TIMEOUT_MS` (default 30000);
206
+ - `WEB_SEARCH_SCRAPE_FALLBACK` (fall back to a DuckDuckGo scrape when no native
207
+ web-search model is deployed; default on);
208
+ - `WEB_SEARCH_ALLOWED_URLS` (comma/space-separated globs; empty = unrestricted).
209
+
210
+ The plugin's `maxCitations` / `fetchMaxLength` are hard caps: a per-call request
211
+ may narrow them but never exceed them.
212
+
213
+ Requirements: the native web-search tool runs on pay-per-token GPT / Gemini
214
+ serving endpoints with cross-region processing enabled; it is unavailable on
215
+ provisioned throughput, for external models, or under HIPAA/BAA compliance. See
216
+ the [Databricks docs](https://docs.databricks.com/aws/en/machine-learning/model-serving/web-search).
217
+
218
+ ## Modules
219
+
220
+ - `plugin` - `WebSearchPlugin`, `webSearch()` AppKit plugin factory.
221
+ - `tool` - `webSearchTool()` / `webFetchTool()` Mastra tools.
222
+ - `search` - `runWebSearch()` over the Databricks native web-search tool.
223
+ - `provider` - provider detection + the provider -> tool-spec map.
224
+ - `scrape` - `runScrapeSearch()` DuckDuckGo fallback for workspaces with no
225
+ native web-search model.
226
+ - `fetch` - `runWebFetch()` over got-scraping, plus `htmlToText()`.
227
+ - `runtime` - shared runtime, `getWebSearchRuntime()`, `resetWebSearchRuntime()`.
228
+ - `config` - config types, JSON schema, `resolveWebSearchConfig()`, approval helpers.
229
+ - `allowlist` - URL allow-list parsing/compiling on top of `@dbx-tools/path`.
230
+ - `schema` - zod tool contracts and inferred types.
231
+
232
+ Pair this package with [`@dbx-tools/appkit-mastra`](../appkit-mastra) to spread
233
+ the tools into an agent (and resolve its own model via [`@dbx-tools/model`](../model)),
234
+ and [`@dbx-tools/email`](../email) for the sibling approval-gated tool pattern.
package/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ // GENERATED by projen watch - DO NOT EDIT.
2
+ // Regenerated from the exporting modules in ./src.
3
+ // Hand edits are overwritten on the next watch; this file is read-only.
4
+
5
+ export * as allowlist from "./src/allowlist";
6
+ export * as config from "./src/config";
7
+ export * as fetch from "./src/fetch";
8
+ export * as plugin from "./src/plugin";
9
+ export * as provider from "./src/provider";
10
+ export * as runtime from "./src/runtime";
11
+ export * as schema from "./src/schema";
12
+ export * as scrape from "./src/scrape";
13
+ export * as search from "./src/search";
14
+ export * as tool from "./src/tool";
15
+ export type { UrlAllowList } from "./src/allowlist";
16
+ export type { ApprovalGate, WebSearchPluginConfig, ResolvedWebSearchConfig } from "./src/config";
17
+ export type { WebSearchProvider, WebSearchProviderSpec } from "./src/provider";
18
+ export type { WebSearchRuntime } from "./src/runtime";
19
+ export type { WebSearchRequest, WebSearchCitation, WebSearchResult, WebFetchRequest, WebFetchResult } from "./src/schema";
20
+ export type { WebSearchContext } from "./src/search";
21
+ export type { WebSearchToolOptions } from "./src/tool";
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@dbx-tools/appkit-web-search",
3
+ "repository": {
4
+ "type": "git",
5
+ "url": "git+https://github.com/reggie-db/dbx-tools.git",
6
+ "directory": "workspaces/node/appkit-web-search"
7
+ },
8
+ "devDependencies": {
9
+ "@types/express": "^5.0.5",
10
+ "@types/json-schema": "^7",
11
+ "@types/node": "^24.6.0",
12
+ "tsx": "^4.23.0",
13
+ "typescript": "^5.9.3"
14
+ },
15
+ "dependencies": {
16
+ "@databricks/appkit": "^0.43.0",
17
+ "@mastra/core": "^1.47.0",
18
+ "got-scraping": "^4.2.1",
19
+ "zod": "^4.3.6",
20
+ "@dbx-tools/shared-core": "0.3.21",
21
+ "@dbx-tools/model": "0.3.21",
22
+ "@dbx-tools/path": "0.3.21",
23
+ "@dbx-tools/shared-model": "0.3.21"
24
+ },
25
+ "main": "index.ts",
26
+ "license": "UNLICENSED",
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "version": "0.3.21",
31
+ "types": "index.ts",
32
+ "type": "module",
33
+ "exports": {
34
+ ".": "./index.ts",
35
+ "./package.json": "./package.json"
36
+ },
37
+ "dbxToolsConfig": {
38
+ "tags": [
39
+ "node"
40
+ ]
41
+ },
42
+ "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"pnpm exec projen\".",
43
+ "scripts": {
44
+ "build": "projen build",
45
+ "compile": "projen compile",
46
+ "default": "projen default",
47
+ "package": "projen package",
48
+ "post-compile": "projen post-compile",
49
+ "pre-compile": "projen pre-compile",
50
+ "test": "projen test",
51
+ "watch": "projen watch",
52
+ "projen": "projen"
53
+ }
54
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * URL allow-list policy for the web-search add-on.
3
+ *
4
+ * A configured allow-list restricts which URLs the tools will surface or
5
+ * fetch. Each entry is a glob compiled by `@dbx-tools/path`'s
6
+ * {@link match.toPathMatcher} (the same `Minimatch`-backed matcher the
7
+ * package's file scanning uses). Because that matcher treats `/` as a
8
+ * path-segment boundary, entries are matched against the right slice of the
9
+ * URL rather than the raw `href`:
10
+ *
11
+ * - A **host** entry (no `/` after the optional scheme, e.g. `databricks.com`
12
+ * or `*.databricks.com`) is tested against the URL's `hostname`. A bare
13
+ * host with no wildcard also matches its subdomains, so `databricks.com`
14
+ * permits `docs.databricks.com` - the intuitive reading of a domain
15
+ * allow-list.
16
+ * - A **path** entry (contains a `/`, e.g. `docs.example.com/api/**`) is
17
+ * tested against `host + pathname` (scheme and query stripped), so path
18
+ * globs work without fighting the `https://` prefix.
19
+ *
20
+ * Enforcement is asymmetric by design (see the module's two consumers):
21
+ * `web_search` results are SILENTLY filtered to the permitted set, while an
22
+ * explicit `web_fetch` of a disallowed URL is refused with an error - the
23
+ * search never leaks a URL the caller then can't fetch, but a direct fetch
24
+ * of a blocked URL is a visible, correctable mistake rather than a silent
25
+ * empty.
26
+ *
27
+ * An empty / absent allow-list permits everything.
28
+ *
29
+ * @module
30
+ */
31
+
32
+ import { object } from "@dbx-tools/shared-core";
33
+ import { match, type PathMatcher } from "@dbx-tools/path";
34
+
35
+ /** A compiled URL allow-list. Build one with {@link toUrlAllowList}. */
36
+ export interface UrlAllowList {
37
+ /** The normalized entries backing this list (for diagnostics). */
38
+ readonly patterns: readonly string[];
39
+ /** Whether the list actually restricts anything (`false` == permit all). */
40
+ readonly restricted: boolean;
41
+ /** Whether `url` is permitted. An unrestricted list permits everything. */
42
+ allows(url: string): boolean;
43
+ }
44
+
45
+ /** Strip a leading `scheme://` (or bare `scheme:`) from a pattern. */
46
+ function stripScheme(pattern: string): string {
47
+ return pattern.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").replace(/^[a-z][a-z0-9+.-]*:/i, "");
48
+ }
49
+
50
+ /**
51
+ * Normalize one raw allow-list entry: trim it and drop any scheme. The
52
+ * scheme is irrelevant to matching (we compare against hostname / host+path),
53
+ * so `https://docs.example.com/x` and `docs.example.com/x` are equivalent.
54
+ */
55
+ export function normalizeUrlPattern(pattern: string): string {
56
+ return stripScheme(pattern.trim());
57
+ }
58
+
59
+ /**
60
+ * Parse a raw allow-list from config (`string[]`) or an env var (a CSV /
61
+ * whitespace-separated string) into a normalized, de-duplicated entry list.
62
+ * Entries are trimmed and scheme-stripped; empties are dropped. Mirrors the
63
+ * email add-on's `parseAllowedSenders` so the two policies read their config
64
+ * identically.
65
+ */
66
+ export function parseAllowedUrls(raw: string | string[] | undefined): string[] {
67
+ const entries = typeof raw === "string" ? raw.split(/[\s,]+/) : Array.isArray(raw) ? raw : [];
68
+ return [
69
+ ...object
70
+ .sequence(entries)
71
+ .map((entry) => normalizeUrlPattern(entry))
72
+ .filter((entry) => entry.length > 0)
73
+ .distinct()
74
+ .toArray(),
75
+ ];
76
+ }
77
+
78
+ /** One compiled entry: which URL slice it tests, and the matcher for it. */
79
+ interface CompiledPattern {
80
+ target: "hostname" | "hostPath";
81
+ matcher: PathMatcher;
82
+ }
83
+
84
+ /** Compile a single normalized entry into a {@link CompiledPattern}. */
85
+ function compilePattern(pattern: string): CompiledPattern {
86
+ if (pattern.includes("/")) {
87
+ // A path entry matches against `host + pathname` (no scheme, no query).
88
+ return { target: "hostPath", matcher: match.toPathMatcher(pattern) };
89
+ }
90
+ // A host entry matches the hostname. A bare host (no wildcard) also
91
+ // permits its subdomains via an added `*.<host>` alternative.
92
+ const globs = pattern.includes("*") ? [pattern] : [pattern, `*.${pattern}`];
93
+ return { target: "hostname", matcher: match.toPathMatcher(...globs) };
94
+ }
95
+
96
+ /** The URL slices a compiled pattern is tested against. */
97
+ function urlTargets(url: string): { hostname: string; hostPath: string } | undefined {
98
+ try {
99
+ const u = new URL(url);
100
+ return {
101
+ hostname: u.hostname,
102
+ // Drop a trailing slash so `example.com/api` matches `example.com/api/`.
103
+ hostPath: `${u.hostname}${u.pathname}`.replace(/\/$/, ""),
104
+ };
105
+ } catch {
106
+ return undefined;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Compile a normalized entry list into a {@link UrlAllowList}. An empty list
112
+ * yields a permit-all allow-list whose {@link UrlAllowList.allows} always
113
+ * returns `true`. A URL that can't be parsed is never permitted by a
114
+ * restricted list.
115
+ */
116
+ export function toUrlAllowList(patterns: readonly string[]): UrlAllowList {
117
+ if (patterns.length === 0) {
118
+ return { patterns: [], restricted: false, allows: () => true };
119
+ }
120
+ const compiled = patterns.map(compilePattern);
121
+ return {
122
+ patterns,
123
+ restricted: true,
124
+ allows: (url: string) => {
125
+ const targets = urlTargets(url);
126
+ if (!targets) return false;
127
+ return compiled.some((c) => c.matcher(targets[c.target]));
128
+ },
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Throw when `url` is not permitted by the allow-list. No-op when the
134
+ * allow-list is unrestricted. The single enforcement point for the
135
+ * `web_fetch` path.
136
+ */
137
+ export function assertUrlAllowed(url: string, allow: UrlAllowList): void {
138
+ if (!allow.allows(url)) {
139
+ throw new Error(
140
+ `web-search: URL "${url}" is not permitted by the configured allow-list (${allow.patterns.join(", ")})`,
141
+ );
142
+ }
143
+ }