@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/README.md +128 -30
- package/index.ts +5 -2
- package/package.json +6 -6
- package/src/allowlist.ts +7 -12
- package/src/config.ts +226 -56
- package/src/defaults.ts +127 -0
- package/src/fetch.ts +49 -54
- package/src/html-text.ts +72 -0
- package/src/plugin.ts +219 -33
- package/src/provider.ts +28 -5
- package/src/runtime.ts +87 -3
- package/src/schema.ts +22 -0
- package/src/scrape.ts +35 -27
- package/src/search.ts +147 -56
- package/src/tool.ts +44 -45
- package/test/allowlist.test.ts +70 -4
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 {
|
|
22
|
-
import { string } from "@dbx-tools/shared-core";
|
|
24
|
+
import { ValidationError } from "@databricks/appkit";
|
|
23
25
|
import { createTool } from "@mastra/core/tools";
|
|
24
|
-
import {
|
|
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
|
-
|
|
33
|
-
|
|
40
|
+
WEB_FETCH_TOOL_DESCRIPTION,
|
|
41
|
+
WEB_SEARCH_TOOL_DESCRIPTION,
|
|
34
42
|
} from "./schema";
|
|
35
|
-
import {
|
|
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):
|
|
51
|
-
return opts.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:
|
|
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"
|
|
97
|
-
...(gate ===
|
|
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
|
-
|
|
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:
|
|
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
|
|
133
|
-
...(gate ===
|
|
130
|
+
// precisely against it.
|
|
131
|
+
...(gate.mode === "none"
|
|
134
132
|
? {}
|
|
135
133
|
: {
|
|
136
134
|
requireApproval: (input: unknown) =>
|
|
137
|
-
|
|
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
|
|
140
|
+
return runWebFetch(parseToolInput(webFetchRequestSchema, input), config);
|
|
142
141
|
},
|
|
143
142
|
});
|
|
144
143
|
}
|
package/test/allowlist.test.ts
CHANGED
|
@@ -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/
|
|
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), /
|
|
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.
|
|
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", () => {
|