@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/src/schema.ts ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Wire-format contract for the web-search add-on: the two tool inputs a
3
+ * model fills in (`web_search`, `web_fetch`) and the results handed back.
4
+ * Pure zod + inferred types (no Node-only imports) so the tool layer, the
5
+ * plugin, and any future UI validate / type against one definition.
6
+ *
7
+ * `web_search` is backed by the Databricks Model Serving native web-search
8
+ * tool (see `provider.ts`): the model searches the web server-side and
9
+ * returns a synthesized answer plus the sources it used. `web_fetch` reads a
10
+ * single page's contents via got-scraping.
11
+ *
12
+ * Array fields intentionally avoid `.min()` / `.nonempty()`: those emit
13
+ * `minItems` in the JSON schema, which some Model Serving endpoints reject
14
+ * ("array types do not support minItems") when the schema is forwarded as a
15
+ * tool definition - the same constraint the email add-on documents.
16
+ *
17
+ * @module
18
+ */
19
+
20
+ import { string } from "@dbx-tools/shared-core";
21
+ import { z } from "zod";
22
+
23
+ /** Schema for the `web_search` tool input. */
24
+ export const webSearchRequestSchema = z.object({
25
+ query: z
26
+ .string()
27
+ .describe(
28
+ "What to search the web for, phrased as a natural-language question or request. The model searches and answers in one step.",
29
+ ),
30
+ model: z
31
+ .string()
32
+ .optional()
33
+ .describe(
34
+ string.toDescription(`
35
+ Optional web-search-capable model to use (a Databricks serving
36
+ endpoint name like "databricks-gemini-3-pro", a loose name like "gpt"
37
+ or "gemini", or a capability class). Defaults to the plugin's
38
+ configured web-search model. The web-search tool resolves its own
39
+ model independently of the calling agent's chat model, since not
40
+ every chat model supports web search.
41
+ `),
42
+ ),
43
+ });
44
+
45
+ /** A validated `web_search` request. */
46
+ export type WebSearchRequest = z.infer<typeof webSearchRequestSchema>;
47
+
48
+ /** Schema for a single source the model cited while answering. */
49
+ export const webSearchCitationSchema = z.object({
50
+ url: z.string().describe("The source URL the answer drew on."),
51
+ title: z.string().optional().describe("The source page title, when available."),
52
+ snippet: z.string().optional().describe("A short excerpt from the source, when available."),
53
+ });
54
+
55
+ /** A single cited source ({@link webSearchCitationSchema}). */
56
+ export type WebSearchCitation = z.infer<typeof webSearchCitationSchema>;
57
+
58
+ /** Schema for the `web_search` tool output. */
59
+ export const webSearchResultSchema = z.object({
60
+ query: z.string().describe("Echo of the query that was searched."),
61
+ answer: z
62
+ .string()
63
+ .describe("The model's answer, synthesized from live web results."),
64
+ citations: z
65
+ .array(webSearchCitationSchema)
66
+ .describe(
67
+ "Sources the answer drew on. When an allow-list is configured, citations whose URL is not permitted are silently omitted.",
68
+ ),
69
+ model: z.string().describe("The serving endpoint that produced the answer."),
70
+ });
71
+
72
+ /** The outcome of a `web_search` call ({@link webSearchResultSchema}). */
73
+ export type WebSearchResult = z.infer<typeof webSearchResultSchema>;
74
+
75
+ /** Schema for the `web_fetch` tool input. */
76
+ export const webFetchRequestSchema = z.object({
77
+ url: z
78
+ .string()
79
+ .describe(
80
+ "The absolute URL to fetch (must include the scheme, e.g. https://). When an allow-list is configured, a URL it does not permit is refused.",
81
+ ),
82
+ format: z
83
+ .enum(["text", "html"])
84
+ .optional()
85
+ .describe(
86
+ string.toDescription(`
87
+ Return format: "text" (default) strips the page to readable plain
88
+ text; "html" returns the raw response body. Prefer "text" unless you
89
+ need the markup.
90
+ `),
91
+ ),
92
+ maxLength: z
93
+ .number()
94
+ .int()
95
+ .positive()
96
+ .optional()
97
+ .describe(
98
+ "Truncate the returned content to at most this many characters (the plugin caps this at its configured limit).",
99
+ ),
100
+ });
101
+
102
+ /** A validated `web_fetch` request. */
103
+ export type WebFetchRequest = z.infer<typeof webFetchRequestSchema>;
104
+
105
+ /** Schema for the `web_fetch` tool output. */
106
+ export const webFetchResultSchema = z.object({
107
+ url: z.string().describe("The final URL fetched (after redirects)."),
108
+ status: z.number().describe("HTTP status code of the response."),
109
+ contentType: z.string().optional().describe("Response `Content-Type`, when the server sent one."),
110
+ title: z.string().optional().describe("The page <title>, when one was present."),
111
+ content: z.string().describe("The page content in the requested format (text or html)."),
112
+ truncated: z.boolean().describe("True when `content` was cut off at the length cap."),
113
+ });
114
+
115
+ /** The outcome of a `web_fetch` call ({@link webFetchResultSchema}). */
116
+ export type WebFetchResult = z.infer<typeof webFetchResultSchema>;
package/src/scrape.ts ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Last-resort scraping fallback for `web_search`, used ONLY when the
3
+ * workspace has no Databricks web-search-capable model deployed (no GPT /
4
+ * Gemini serving endpoint). The native Databricks web-search tool is always
5
+ * preferred (see `search.ts`); this exists so the tool still returns useful
6
+ * results in an environment that can't run it, rather than erroring on every
7
+ * call.
8
+ *
9
+ * It queries DuckDuckGo's no-JS HTML endpoint through `got-scraping`
10
+ * (browser-like fingerprints so the request isn't blocked) via a GET with the
11
+ * query in the query string - a POST to the same endpoint trips DDG's bot
12
+ * challenge (HTTP 202), while the GET returns normal result markup. It then
13
+ * parses the result anchors + snippets. Unlike the native tool there is no
14
+ * model synthesizing an answer, so `answer` is a short lead-in over the top
15
+ * snippets and the substance rides in `citations` - the calling agent reads
16
+ * those and writes its own answer.
17
+ *
18
+ * @module
19
+ */
20
+
21
+ import { log } from "@dbx-tools/shared-core";
22
+ import { gotScraping } from "got-scraping";
23
+ import type { ResolvedWebSearchConfig } from "./config";
24
+ import type { WebSearchCitation, WebSearchRequest, WebSearchResult } from "./schema";
25
+
26
+ const logger = log.logger("web-search/scrape");
27
+
28
+ /** DuckDuckGo's no-JS HTML results endpoint (queried via GET). */
29
+ const DDG_HTML_URL = "https://html.duckduckgo.com/html/";
30
+
31
+ /** Strip HTML tags and decode the few entities DDG emits in titles/snippets. */
32
+ function stripTags(html: string): string {
33
+ return html
34
+ .replace(/<[^>]+>/g, "")
35
+ .replace(/&amp;/g, "&")
36
+ .replace(/&lt;/g, "<")
37
+ .replace(/&gt;/g, ">")
38
+ .replace(/&quot;/g, '"')
39
+ .replace(/&#x27;|&#39;/g, "'")
40
+ .replace(/&nbsp;/g, " ")
41
+ .replace(/\s+/g, " ")
42
+ .trim();
43
+ }
44
+
45
+ /**
46
+ * DDG wraps result URLs in a redirect (`//duckduckgo.com/l/?uddg=<encoded>`).
47
+ * Unwrap to the real destination; leave already-absolute URLs untouched.
48
+ */
49
+ function unwrapDdgUrl(href: string): string {
50
+ try {
51
+ const u = new URL(href, "https://duckduckgo.com");
52
+ const target = u.searchParams.get("uddg");
53
+ if (target) return decodeURIComponent(target);
54
+ return u.protocol === "http:" || u.protocol === "https:" ? u.toString() : href;
55
+ } catch {
56
+ return href;
57
+ }
58
+ }
59
+
60
+ /** Parse DDG HTML result blocks into citations (title, url, snippet). */
61
+ function parseDdgHtml(html: string): WebSearchCitation[] {
62
+ const citations: WebSearchCitation[] = [];
63
+ // Each result: an anchor with class result__a (title + href) and a snippet
64
+ // with class result__snippet. Match anchors, then the following snippet.
65
+ const anchorRe = /<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
66
+ const snippetRe = /<a[^>]*class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/a>/g;
67
+ const snippets: string[] = [];
68
+ let sm: RegExpExecArray | null;
69
+ while ((sm = snippetRe.exec(html)) !== null) snippets.push(stripTags(sm[1] ?? ""));
70
+ let am: RegExpExecArray | null;
71
+ let i = 0;
72
+ while ((am = anchorRe.exec(html)) !== null) {
73
+ const url = unwrapDdgUrl(am[1] ?? "");
74
+ const title = stripTags(am[2] ?? "");
75
+ if (!url || !title) continue;
76
+ const snippet = snippets[i] ?? "";
77
+ citations.push({ url, title, ...(snippet ? { snippet } : {}) });
78
+ i += 1;
79
+ }
80
+ return citations;
81
+ }
82
+
83
+ /**
84
+ * Run a scraping search over DuckDuckGo. Returns the same
85
+ * {@link WebSearchResult} shape as the native path, with `model` set to
86
+ * `"scrape:duckduckgo"` so callers can tell how the result was produced.
87
+ * Citations are filtered through the configured URL allow-list.
88
+ */
89
+ export async function runScrapeSearch(
90
+ request: WebSearchRequest,
91
+ config: ResolvedWebSearchConfig,
92
+ ): Promise<WebSearchResult> {
93
+ const response = await gotScraping({
94
+ url: `${DDG_HTML_URL}?q=${encodeURIComponent(request.query)}`,
95
+ method: "GET",
96
+ timeout: { request: config.timeoutMs },
97
+ throwHttpErrors: false,
98
+ followRedirect: true,
99
+ });
100
+ const body = typeof response.body === "string" ? response.body : String(response.body ?? "");
101
+ const all = parseDdgHtml(body);
102
+ const permitted = all.filter((c) => config.allowList.allows(c.url));
103
+ const citations = permitted.slice(0, config.maxCitations);
104
+ const answer =
105
+ citations.length > 0
106
+ ? `Web results for "${request.query}" (no Databricks web-search model is deployed in this workspace, so these are unsynthesized search results - read the citations):\n\n` +
107
+ citations.map((c, n) => `${n + 1}. ${c.title}${c.snippet ? ` - ${c.snippet}` : ""} (${c.url})`).join("\n")
108
+ : `No web results found for "${request.query}".`;
109
+ logger.debug("scraped", {
110
+ query: request.query,
111
+ found: all.length,
112
+ returned: citations.length,
113
+ status: response.statusCode,
114
+ });
115
+ return { query: request.query, answer, citations, model: "scrape:duckduckgo" };
116
+ }
package/src/search.ts ADDED
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Web search backed by the Databricks Model Serving native web-search tool.
3
+ *
4
+ * Unlike a scraping client, the search runs *inside* a model call: we POST the
5
+ * query to the workspace's serving endpoint with the provider's web-search
6
+ * tool spec attached, and the model searches the web and writes the answer.
7
+ * {@link runWebSearch}:
8
+ *
9
+ * 1. Resolves a web-search-capable model INDEPENDENTLY of the calling
10
+ * agent's chat model (the agent may run on a model without web search).
11
+ * A pinned `model` (request or config) is fuzzy-matched; otherwise the
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.
15
+ * 2. POSTs to the provider's REST surface (`/serving-endpoints/responses`
16
+ * for OpenAI, `/serving-endpoints/chat/completions` for Gemini) with the
17
+ * mapped tool spec, using the OBO-scoped workspace client.
18
+ * 3. Returns the synthesized answer plus the cited sources, with citations
19
+ * silently filtered through the configured URL allow-list.
20
+ *
21
+ * @module
22
+ */
23
+
24
+ import { error, log } from "@dbx-tools/shared-core";
25
+ import { resolve, serving } from "@dbx-tools/model";
26
+ import type { ResolvedWebSearchConfig } from "./config";
27
+ import { detectWebSearchProvider, supportsWebSearch, webSearchToolSpec } from "./provider";
28
+ import type { WebSearchCitation, WebSearchRequest, WebSearchResult } from "./schema";
29
+ import { runScrapeSearch } from "./scrape";
30
+
31
+ type WorkspaceClientLike = serving.WorkspaceClientLike;
32
+ const logger = log.logger("web-search/search");
33
+ const { resolveModel } = resolve;
34
+ const { listServingEndpoints } = serving;
35
+
36
+ /** Context a search needs from the caller: the OBO client + workspace host. */
37
+ export interface WebSearchContext {
38
+ client: WorkspaceClientLike;
39
+ host: string;
40
+ }
41
+
42
+ /**
43
+ * Resolve a web-search-capable model against the LIVE workspace catalogue - so
44
+ * we never return an endpoint id that isn't actually deployed (the "endpoint
45
+ * does not exist" failure a hardcoded fallback id would cause). Reuses
46
+ * `@dbx-tools/model`'s existing catalogue + resolver rather than a custom
47
+ * lookup: {@link listServingEndpoints} lists the endpoints (cached), and we
48
+ * restrict the candidate set to the {@link supportsWebSearch} ones before
49
+ * {@link resolveModel} fuzzy-picks within it.
50
+ *
51
+ * Returns the chosen endpoint id, or `null` when the workspace has no
52
+ * web-search-capable model deployed (the caller then uses the scrape
53
+ * fallback). An explicit request that resolves to an unsupported / absent
54
+ * model throws, so a deliberate bad pin surfaces rather than silently
55
+ * degrading.
56
+ */
57
+ async function resolveWebSearchModel(
58
+ ctx: WebSearchContext,
59
+ config: ResolvedWebSearchConfig,
60
+ requested: string | undefined,
61
+ ): Promise<string | null> {
62
+ const endpoints = await listServingEndpoints(ctx.client, ctx.host);
63
+ // Only deployed, web-search-capable endpoints are candidates.
64
+ const capable = endpoints.filter((e) => supportsWebSearch(e.name));
65
+ const pinned = requested ?? config.model;
66
+
67
+ if (pinned) {
68
+ // Resolve the explicit ask within the capable set only.
69
+ const { modelId } = resolveModel(capable, {
70
+ explicit: pinned,
71
+ fuzzy: config.fuzzy,
72
+ threshold: config.fuzzyThreshold,
73
+ });
74
+ // resolveModel returns the input verbatim on no match; require it to be a
75
+ // real capable endpoint so a bad pin is a clear error, not a phantom call.
76
+ if (!capable.some((e) => e.name === modelId) || !supportsWebSearch(modelId)) {
77
+ throw new Error(
78
+ `web-search: requested model "${pinned}" is not a deployed web-search-capable endpoint. ` +
79
+ `Deployed GPT/Gemini endpoints: [${capable.map((e) => e.name).join(", ") || "none"}].`,
80
+ );
81
+ }
82
+ return modelId;
83
+ }
84
+
85
+ if (capable.length === 0) return null;
86
+
87
+ // Nothing pinned: prefer the configured fallback order (Gemini, then GPT)
88
+ // when those ids are actually deployed; else take the best capable endpoint.
89
+ const { modelId } = resolveModel(capable, {
90
+ fallbacks: config.modelFallbacks,
91
+ fuzzy: config.fuzzy,
92
+ threshold: config.fuzzyThreshold,
93
+ });
94
+ return capable.some((e) => e.name === modelId) ? modelId : (capable[0]?.name ?? null);
95
+ }
96
+
97
+ /** POST a serving request through the OBO client and return the parsed JSON. */
98
+ async function postServing(
99
+ ctx: WebSearchContext,
100
+ path: string,
101
+ body: unknown,
102
+ ): Promise<Record<string, unknown>> {
103
+ const res = await ctx.client.apiClient.request({
104
+ path,
105
+ method: "POST",
106
+ headers: new Headers({ Accept: "application/json", "Content-Type": "application/json" }),
107
+ raw: false,
108
+ payload: body,
109
+ });
110
+ return (res ?? {}) as Record<string, unknown>;
111
+ }
112
+
113
+ /* --------------------------- response extraction --------------------------- */
114
+
115
+ /** Coerce an unknown to a trimmed string, or "" . */
116
+ function str(v: unknown): string {
117
+ return typeof v === "string" ? v.trim() : "";
118
+ }
119
+
120
+ /**
121
+ * Extract answer text + citations from an OpenAI Responses API payload. The
122
+ * Responses API returns an `output` array of items; message items carry
123
+ * `content` parts with `text` and optional `annotations` (url_citation).
124
+ * `output_text` is the convenience aggregate when present.
125
+ */
126
+ function fromResponsesPayload(payload: Record<string, unknown>): {
127
+ answer: string;
128
+ citations: WebSearchCitation[];
129
+ } {
130
+ const citations: WebSearchCitation[] = [];
131
+ const texts: string[] = [];
132
+ const output = Array.isArray(payload["output"]) ? (payload["output"] as unknown[]) : [];
133
+ for (const item of output) {
134
+ const content = (item as { content?: unknown }).content;
135
+ if (!Array.isArray(content)) continue;
136
+ for (const part of content) {
137
+ const p = part as { text?: unknown; annotations?: unknown };
138
+ const text = str(p.text);
139
+ if (text) texts.push(text);
140
+ if (Array.isArray(p.annotations)) {
141
+ for (const ann of p.annotations) {
142
+ const a = ann as { url?: unknown; title?: unknown };
143
+ const url = str(a.url);
144
+ if (url) citations.push({ url, ...(str(a.title) ? { title: str(a.title) } : {}) });
145
+ }
146
+ }
147
+ }
148
+ }
149
+ const answer = str(payload["output_text"]) || texts.join("\n").trim();
150
+ return { answer, citations };
151
+ }
152
+
153
+ /**
154
+ * Extract answer text + citations from a Chat Completions payload (Gemini via
155
+ * `google_search`). The answer is `choices[0].message.content`; grounding
156
+ * sources, when present, surface under `choices[0].message` grounding
157
+ * metadata (best-effort - shapes vary, so we scan for url-bearing entries).
158
+ */
159
+ function fromChatPayload(payload: Record<string, unknown>): {
160
+ answer: string;
161
+ citations: WebSearchCitation[];
162
+ } {
163
+ const choices = Array.isArray(payload["choices"]) ? (payload["choices"] as unknown[]) : [];
164
+ const message = (choices[0] as { message?: Record<string, unknown> })?.message ?? {};
165
+ const answer = str(message["content"]);
166
+ const citations: WebSearchCitation[] = [];
167
+ // Best-effort grounding extraction: walk any nested object for {uri|url,title}.
168
+ const seen = new Set<string>();
169
+ const visit = (v: unknown, depth: number): void => {
170
+ if (depth > 6 || v === null || typeof v !== "object") return;
171
+ const o = v as Record<string, unknown>;
172
+ const url = str(o["url"]) || str(o["uri"]);
173
+ if (url && !seen.has(url)) {
174
+ seen.add(url);
175
+ citations.push({ url, ...(str(o["title"]) ? { title: str(o["title"]) } : {}) });
176
+ }
177
+ for (const val of Object.values(o)) {
178
+ if (Array.isArray(val)) val.forEach((x) => visit(x, depth + 1));
179
+ else if (val && typeof val === "object") visit(val, depth + 1);
180
+ }
181
+ };
182
+ visit(message["grounding_metadata"] ?? message["groundingMetadata"], 0);
183
+ return { answer, citations };
184
+ }
185
+
186
+ /**
187
+ * Run a web search. Prefers the Databricks native web-search tool on a
188
+ * deployed GPT/Gemini endpoint (synthesized answer + citations); when the
189
+ * workspace has no such endpoint AND the scrape fallback is enabled, falls
190
+ * back to a DuckDuckGo scrape so the tool still returns results instead of
191
+ * erroring. Citations are filtered through the configured URL allow-list.
192
+ */
193
+ export async function runWebSearch(
194
+ request: WebSearchRequest,
195
+ config: ResolvedWebSearchConfig,
196
+ ctx: WebSearchContext,
197
+ ): Promise<WebSearchResult> {
198
+ const modelId = await resolveWebSearchModel(ctx, config, request.model);
199
+
200
+ if (modelId === null) {
201
+ // No native web-search model deployed in this workspace.
202
+ if (config.scrapeFallback) {
203
+ logger.info("no-native-model:scrape-fallback", { query: request.query });
204
+ return runScrapeSearch(request, config);
205
+ }
206
+ throw new Error(
207
+ "web-search: no web-search-capable model (GPT/Gemini) is deployed in this workspace, " +
208
+ "and the scrape fallback is disabled. Deploy a supported endpoint, set `model` / " +
209
+ "WEB_SEARCH_MODEL, or enable the fallback (WEB_SEARCH_SCRAPE_FALLBACK=1).",
210
+ );
211
+ }
212
+
213
+ const provider = detectWebSearchProvider(modelId)!; // guaranteed by resolve step
214
+ const spec = webSearchToolSpec(provider, config.webSearchTools);
215
+
216
+ const path =
217
+ spec.api === "responses"
218
+ ? "/serving-endpoints/responses"
219
+ : "/serving-endpoints/chat/completions";
220
+ const body =
221
+ spec.api === "responses"
222
+ ? {
223
+ model: modelId,
224
+ input: [{ role: "user", content: request.query }],
225
+ tools: [spec.tool],
226
+ }
227
+ : {
228
+ model: modelId,
229
+ messages: [{ role: "user", content: request.query }],
230
+ tools: [spec.tool],
231
+ };
232
+
233
+ let payload: Record<string, unknown>;
234
+ try {
235
+ payload = await postServing(ctx, path, body);
236
+ } catch (err) {
237
+ logger.warn("serving-error", { model: modelId, provider, error: error.errorMessage(err) });
238
+ throw err;
239
+ }
240
+
241
+ const { answer, citations } =
242
+ spec.api === "responses" ? fromResponsesPayload(payload) : fromChatPayload(payload);
243
+
244
+ const permitted = citations.filter((c) => config.allowList.allows(c.url));
245
+ const dropped = citations.length - permitted.length;
246
+ const trimmed = permitted.slice(0, config.maxCitations);
247
+ logger.debug("searched", {
248
+ query: request.query,
249
+ model: modelId,
250
+ provider,
251
+ citations: citations.length,
252
+ ...(dropped > 0 ? { filtered: dropped } : {}),
253
+ returned: trimmed.length,
254
+ });
255
+
256
+ return { query: request.query, answer, citations: trimmed, model: modelId };
257
+ }
package/src/tool.ts ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The `web_search` and `web_fetch` Mastra tools.
3
+ *
4
+ * `web_search` is backed by the Databricks Model Serving native web-search
5
+ * tool: it resolves its own web-search-capable model (see `search.ts`) and
6
+ * calls the workspace serving endpoint under the caller's OBO scope, so the
7
+ * search runs as the requesting user and independently of the agent's chat
8
+ * model. `web_fetch` reads a page via got-scraping.
9
+ *
10
+ * Both are read-only and run without approval by default; each accepts an
11
+ * optional {@link ApprovalGate} (`approval`) that maps onto Mastra's
12
+ * `requireApproval`. `true` gates every call; a URL-pattern (or {@link OneOrMany}
13
+ * list) gates only calls whose URL matches - for `web_fetch` that is evaluated
14
+ * against the target URL, while `web_search` (whose result URLs aren't known
15
+ * before the call) treats a pattern gate as "always gate". `approval` falls
16
+ * back to the plugin's `approval` config when a tool omits its own.
17
+ *
18
+ * @module
19
+ */
20
+
21
+ import { getExecutionContext } from "@databricks/appkit";
22
+ import { string } from "@dbx-tools/shared-core";
23
+ import { createTool } from "@mastra/core/tools";
24
+ import { approvalMatches, type ApprovalGate } from "./config";
25
+ import { runWebFetch } from "./fetch";
26
+ import { getWebSearchRuntime } from "./runtime";
27
+ import {
28
+ webFetchRequestSchema,
29
+ webFetchResultSchema,
30
+ webSearchRequestSchema,
31
+ webSearchResultSchema,
32
+ type WebFetchRequest,
33
+ type WebSearchRequest,
34
+ } from "./schema";
35
+ import { runWebSearch, type WebSearchContext } from "./search";
36
+
37
+ /** Options shared by both web tools. */
38
+ export interface WebSearchToolOptions {
39
+ /** Override the tool id. */
40
+ id?: string;
41
+ /**
42
+ * Approval gate for this tool, overriding the plugin's `approval`. `true`
43
+ * gates every call; a URL-pattern (or list) gates only matching calls;
44
+ * omit / `false` for no approval. See {@link ApprovalGate}.
45
+ */
46
+ approval?: ApprovalGate;
47
+ }
48
+
49
+ /** 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 };
64
+ }
65
+
66
+ /**
67
+ * Build the `web_search` tool. Spread it into the agents that should be able
68
+ * to search the web.
69
+ *
70
+ * @example
71
+ * ```ts
72
+ * import { webSearchTool } from "@dbx-tools/appkit-web-search";
73
+ * import { createAgent } from "@dbx-tools/appkit-mastra";
74
+ *
75
+ * const researcher = createAgent({
76
+ * instructions: "...",
77
+ * tools: () => ({ web_search: webSearchTool() }),
78
+ * });
79
+ * ```
80
+ */
81
+ export function webSearchTool(opts: WebSearchToolOptions = {}) {
82
+ const gate = effectiveGate(opts);
83
+ return createTool({
84
+ 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
+ `),
93
+ inputSchema: webSearchRequestSchema,
94
+ outputSchema: webSearchResultSchema,
95
+ // 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) }),
100
+ execute: async (input) => {
101
+ const { config } = getWebSearchRuntime();
102
+ return runWebSearch(input as WebSearchRequest, config, await webSearchContext());
103
+ },
104
+ });
105
+ }
106
+
107
+ /**
108
+ * Build the `web_fetch` tool. Spread it into the agents that should be able
109
+ * to read a page's contents.
110
+ *
111
+ * @example
112
+ * ```ts
113
+ * import { webFetchTool } from "@dbx-tools/appkit-web-search";
114
+ *
115
+ * tools: () => ({ web_fetch: webFetchTool({ approval: "*.internal.example.com" }) })
116
+ * ```
117
+ */
118
+ export function webFetchTool(opts: WebSearchToolOptions = {}) {
119
+ const gate = effectiveGate(opts);
120
+ return createTool({
121
+ 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
+ `),
129
+ inputSchema: webFetchRequestSchema,
130
+ outputSchema: webFetchResultSchema,
131
+ // 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
134
+ ? {}
135
+ : {
136
+ requireApproval: (input: unknown) =>
137
+ approvalMatches(gate, [(input as WebFetchRequest).url]),
138
+ }),
139
+ execute: async (input) => {
140
+ const { config } = getWebSearchRuntime();
141
+ return runWebFetch(input as WebFetchRequest, config);
142
+ },
143
+ });
144
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,41 @@
1
+ // ~~ Generated by projen. To modify, edit .projenrc.js and run "pnpm exec projen".
2
+ {
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "lib",
6
+ "alwaysStrict": true,
7
+ "declaration": true,
8
+ "esModuleInterop": true,
9
+ "experimentalDecorators": true,
10
+ "inlineSourceMap": true,
11
+ "inlineSources": true,
12
+ "lib": [
13
+ "ES2022"
14
+ ],
15
+ "module": "ESNext",
16
+ "noEmitOnError": false,
17
+ "noFallthroughCasesInSwitch": true,
18
+ "noImplicitAny": true,
19
+ "noImplicitReturns": true,
20
+ "noImplicitThis": true,
21
+ "noUnusedLocals": true,
22
+ "noUnusedParameters": true,
23
+ "resolveJsonModule": true,
24
+ "strict": true,
25
+ "strictNullChecks": true,
26
+ "strictPropertyInitialization": true,
27
+ "stripInternal": true,
28
+ "target": "ES2022",
29
+ "types": [
30
+ "node"
31
+ ],
32
+ "moduleResolution": "bundler",
33
+ "skipLibCheck": true
34
+ },
35
+ "include": [
36
+ "src/**/*.ts"
37
+ ],
38
+ "exclude": [
39
+ "node_modules"
40
+ ]
41
+ }