@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/README.md +125 -29
- package/index.ts +4 -2
- package/package.json +6 -6
- package/src/allowlist.ts +5 -2
- package/src/config.ts +218 -33
- package/src/defaults.ts +127 -0
- package/src/fetch.ts +48 -21
- package/src/html-text.ts +3 -3
- package/src/plugin.ts +217 -25
- package/src/provider.ts +28 -5
- package/src/runtime.ts +87 -3
- package/src/schema.ts +22 -0
- package/src/scrape.ts +32 -11
- package/src/search.ts +131 -47
- package/src/tool.ts +43 -31
- package/test/allowlist.test.ts +69 -3
package/src/plugin.ts
CHANGED
|
@@ -1,31 +1,105 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* AppKit plugin (registered name: `web-search`) that owns the resolved
|
|
3
|
-
* web-search runtime - the URL
|
|
4
|
-
*
|
|
5
|
-
* read. Registering it resolves and logs the effective config (
|
|
6
|
-
*
|
|
7
|
-
*
|
|
3
|
+
* web-search runtime - the URL policy, result / length caps, timeout, and
|
|
4
|
+
* default approval gate the {@link webSearchTool} / {@link webFetchTool}
|
|
5
|
+
* read. Registering it resolves and logs the effective config (which URL
|
|
6
|
+
* policy is in force, the caps) so a misconfiguration is visible in the boot
|
|
7
|
+
* logs rather than on the first search, and installs the plugin's
|
|
8
|
+
* `execute()` as the runtime's executor so every outbound call picks up
|
|
9
|
+
* AppKit's cache / retry / timeout / telemetry chain.
|
|
8
10
|
*
|
|
9
|
-
* The
|
|
10
|
-
*
|
|
11
|
-
*
|
|
11
|
+
* The plugin also implements AppKit's `ToolProvider`, so an AppKit agent gets
|
|
12
|
+
* `web_search` / `web_fetch` without going through Mastra; the Mastra tools in
|
|
13
|
+
* `tool.ts` are the other half and share the same runtime.
|
|
12
14
|
*
|
|
13
15
|
* @module
|
|
14
16
|
*/
|
|
15
17
|
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
import {
|
|
19
|
+
Plugin,
|
|
20
|
+
ResourceType,
|
|
21
|
+
toPlugin,
|
|
22
|
+
type PluginManifest,
|
|
23
|
+
type ResourceRequirement,
|
|
24
|
+
} from "@databricks/appkit";
|
|
25
|
+
import {
|
|
26
|
+
defineTool,
|
|
27
|
+
executeFromRegistry,
|
|
28
|
+
toolsFromRegistry,
|
|
29
|
+
type AgentToolDefinition,
|
|
30
|
+
type ToolProvider,
|
|
31
|
+
type ToolRegistry,
|
|
32
|
+
} from "@databricks/appkit/beta";
|
|
33
|
+
import { log, string } from "@dbx-tools/shared-core";
|
|
34
|
+
import {
|
|
35
|
+
MODEL_ENV,
|
|
36
|
+
SERVING_ENDPOINT_ENV,
|
|
37
|
+
WEB_SEARCH_CONFIG_SCHEMA,
|
|
38
|
+
type WebSearchPluginConfig,
|
|
39
|
+
} from "./config";
|
|
19
40
|
import { runWebFetch } from "./fetch";
|
|
20
|
-
import { getWebSearchRuntime } from "./runtime";
|
|
41
|
+
import { getWebSearchRuntime, resetWebSearchRuntime, setWebSearchExecutor } from "./runtime";
|
|
42
|
+
import {
|
|
43
|
+
webFetchRequestSchema,
|
|
44
|
+
webSearchRequestSchema,
|
|
45
|
+
WEB_FETCH_TOOL_DESCRIPTION,
|
|
46
|
+
WEB_SEARCH_TOOL_DESCRIPTION,
|
|
47
|
+
} from "./schema";
|
|
21
48
|
import type { WebFetchRequest, WebFetchResult, WebSearchRequest, WebSearchResult } from "./schema";
|
|
22
49
|
import { resolveWebSearchContext, runWebSearch } from "./search";
|
|
23
50
|
|
|
51
|
+
const logger = log.logger("web-search");
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The Model Serving endpoint the native web-search tool runs on. Declared
|
|
55
|
+
* optional because the plugin resolves a web-search-capable endpoint from the
|
|
56
|
+
* live catalogue when nothing is pinned; {@link WebSearchPlugin.getResourceRequirements}
|
|
57
|
+
* promotes it to required once a deployment names one. `CAN_QUERY` is the
|
|
58
|
+
* weakest permission that can invoke an endpoint.
|
|
59
|
+
*/
|
|
60
|
+
const SERVING_ENDPOINT_RESOURCE = {
|
|
61
|
+
type: ResourceType.SERVING_ENDPOINT,
|
|
62
|
+
alias: "Web Search Endpoint",
|
|
63
|
+
resourceKey: "web-search-endpoint",
|
|
64
|
+
description:
|
|
65
|
+
"Model Serving endpoint running the native web-search tool. Optional: resolved from the " +
|
|
66
|
+
"workspace catalogue (Gemini, then GPT) when no endpoint is pinned.",
|
|
67
|
+
permission: "CAN_QUERY",
|
|
68
|
+
fields: {
|
|
69
|
+
name: {
|
|
70
|
+
env: SERVING_ENDPOINT_ENV,
|
|
71
|
+
description: `Serving endpoint name for web search. ${MODEL_ENV} overrides it.`,
|
|
72
|
+
discovery: {
|
|
73
|
+
type: "cli",
|
|
74
|
+
cliCommand: "databricks serving-endpoints list --profile <PROFILE> --output json",
|
|
75
|
+
selectField: ".name",
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
} satisfies Omit<ResourceRequirement, "required">;
|
|
80
|
+
|
|
24
81
|
/**
|
|
25
82
|
* AppKit plugin that resolves and holds the web-search runtime config used
|
|
26
83
|
* by the `web_search` / `web_fetch` tools.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```ts
|
|
87
|
+
* import { createApp, server } from "@databricks/appkit";
|
|
88
|
+
* import { plugin as webSearchPlugin } from "@dbx-tools/appkit-web-search";
|
|
89
|
+
*
|
|
90
|
+
* await createApp({
|
|
91
|
+
* plugins: [
|
|
92
|
+
* server(),
|
|
93
|
+
* webSearchPlugin.webSearch({
|
|
94
|
+
* model: "gemini",
|
|
95
|
+
* urlPolicy: "allowlist",
|
|
96
|
+
* allowedUrls: ["*.databricks.com"],
|
|
97
|
+
* }),
|
|
98
|
+
* ],
|
|
99
|
+
* });
|
|
100
|
+
* ```
|
|
27
101
|
*/
|
|
28
|
-
export class WebSearchPlugin extends Plugin<WebSearchPluginConfig> {
|
|
102
|
+
export class WebSearchPlugin extends Plugin<WebSearchPluginConfig> implements ToolProvider {
|
|
29
103
|
static manifest = {
|
|
30
104
|
name: "web-search",
|
|
31
105
|
displayName: "Web Search",
|
|
@@ -36,29 +110,111 @@ export class WebSearchPlugin extends Plugin<WebSearchPluginConfig> {
|
|
|
36
110
|
stability: "beta",
|
|
37
111
|
resources: {
|
|
38
112
|
required: [],
|
|
39
|
-
optional: [],
|
|
113
|
+
optional: [SERVING_ENDPOINT_RESOURCE],
|
|
40
114
|
},
|
|
41
115
|
config: { schema: WEB_SEARCH_CONFIG_SCHEMA },
|
|
42
116
|
} satisfies PluginManifest<"web-search">;
|
|
43
117
|
|
|
44
|
-
|
|
118
|
+
/**
|
|
119
|
+
* Promote the serving endpoint to a required resource once a deployment
|
|
120
|
+
* pins one, through plugin config or either environment name. Left optional
|
|
121
|
+
* otherwise, because the plugin picks a web-search-capable endpoint out of
|
|
122
|
+
* the live catalogue on its own.
|
|
123
|
+
*/
|
|
124
|
+
static getResourceRequirements(config: WebSearchPluginConfig): ResourceRequirement[] {
|
|
125
|
+
const pinned =
|
|
126
|
+
string.trimToNull(config.model) ??
|
|
127
|
+
string.trimToNull(process.env[MODEL_ENV]) ??
|
|
128
|
+
string.trimToNull(process.env[SERVING_ENDPOINT_ENV]);
|
|
129
|
+
return pinned === null ? [] : [{ ...SERVING_ENDPOINT_RESOURCE, required: true }];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The tools this plugin offers to an AppKit agent. Both are reads and both
|
|
134
|
+
* run under the caller's identity.
|
|
135
|
+
*
|
|
136
|
+
* Neither is `autoInheritable`. `web_fetch` reaches a URL the model chose,
|
|
137
|
+
* which is reachable from inside the workspace network, so it must only
|
|
138
|
+
* appear in an agent that asked for it and accepted the URL policy that
|
|
139
|
+
* comes with it. `web_search` is safer but still spends serving tokens on
|
|
140
|
+
* every call, so it is opt-in for cost rather than for safety.
|
|
141
|
+
*
|
|
142
|
+
* `execute` re-parses its arguments with the local schema: AppKit validates
|
|
143
|
+
* against the same schema first, but re-parsing is what gives the body typed
|
|
144
|
+
* arguments instead of `unknown`.
|
|
145
|
+
*/
|
|
146
|
+
private readonly tools: ToolRegistry = {
|
|
147
|
+
web_search: defineTool({
|
|
148
|
+
description: WEB_SEARCH_TOOL_DESCRIPTION,
|
|
149
|
+
schema: webSearchRequestSchema,
|
|
150
|
+
annotations: { effect: "read", requiresUserContext: true },
|
|
151
|
+
autoInheritable: false,
|
|
152
|
+
execute: async (args, signal) => this.search(webSearchRequestSchema.parse(args), signal),
|
|
153
|
+
}),
|
|
154
|
+
web_fetch: defineTool({
|
|
155
|
+
description: WEB_FETCH_TOOL_DESCRIPTION,
|
|
156
|
+
schema: webFetchRequestSchema,
|
|
157
|
+
annotations: { effect: "read", requiresUserContext: true },
|
|
158
|
+
autoInheritable: false,
|
|
159
|
+
execute: async (args, signal) => this.fetch(webFetchRequestSchema.parse(args), signal),
|
|
160
|
+
}),
|
|
161
|
+
};
|
|
45
162
|
|
|
46
163
|
/**
|
|
47
|
-
* Prime the shared runtime from this plugin's config (over env)
|
|
48
|
-
*
|
|
164
|
+
* Prime the shared runtime from this plugin's config (over env), route the
|
|
165
|
+
* tools' outbound calls through this plugin's interceptor chain, and log the
|
|
166
|
+
* effective policy so an active allow-list / caps are obvious at boot.
|
|
49
167
|
*/
|
|
50
168
|
override async setup(): Promise<void> {
|
|
51
169
|
const { config } = getWebSearchRuntime(this.config);
|
|
52
|
-
this.
|
|
170
|
+
setWebSearchExecutor((fn, settings) => this.execute(fn, settings));
|
|
171
|
+
logger.info("ready", {
|
|
53
172
|
model: config.model ?? `fallbacks:[${config.modelFallbacks.join(", ")}]`,
|
|
54
|
-
|
|
173
|
+
modelSource: config.modelSource,
|
|
174
|
+
urlPolicy: config.urlPolicy,
|
|
55
175
|
...(config.allowList.restricted ? { allowedUrls: config.allowList.patterns } : {}),
|
|
56
176
|
maxCitations: config.maxCitations,
|
|
57
177
|
fetchMaxLength: config.fetchMaxLength,
|
|
58
|
-
approval: config.approval
|
|
178
|
+
approval: config.approval.mode,
|
|
179
|
+
scrapeFallback: config.scrapeFallback,
|
|
59
180
|
});
|
|
60
181
|
}
|
|
61
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Drop the shared runtime so a restarted app re-resolves config and does not
|
|
185
|
+
* keep calling through a torn-down plugin's `execute()`. Bounded and
|
|
186
|
+
* idempotent: there is no connection to drain, only the memo to clear.
|
|
187
|
+
*/
|
|
188
|
+
async shutdown(): Promise<void> {
|
|
189
|
+
resetWebSearchRuntime();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Abort in-flight work. AppKit's graceful shutdown only invokes this hook -
|
|
194
|
+
* it never calls {@link shutdown} - so the runtime memo is dropped from here
|
|
195
|
+
* to keep a restarted app from calling through a torn-down `execute()`. The
|
|
196
|
+
* teardown is synchronous and idempotent, so the un-awaited call costs
|
|
197
|
+
* nothing.
|
|
198
|
+
*/
|
|
199
|
+
override abortActiveOperations(): void {
|
|
200
|
+
super.abortActiveOperations();
|
|
201
|
+
void this.shutdown();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** AppKit `ToolProvider`: the tool definitions offered to an agent. */
|
|
205
|
+
getAgentTools(): AgentToolDefinition[] {
|
|
206
|
+
return toolsFromRegistry(this.tools);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* AppKit `ToolProvider`: run one tool call. Arguments are validated against
|
|
211
|
+
* the tool's schema first, and a validation failure comes back as an
|
|
212
|
+
* LLM-friendly string so the model can correct itself on the next turn.
|
|
213
|
+
*/
|
|
214
|
+
async executeAgentTool(name: string, args: unknown, signal?: AbortSignal): Promise<unknown> {
|
|
215
|
+
return executeFromRegistry(this.tools, name, args, signal);
|
|
216
|
+
}
|
|
217
|
+
|
|
62
218
|
override exports() {
|
|
63
219
|
return {
|
|
64
220
|
/**
|
|
@@ -66,16 +222,52 @@ export class WebSearchPlugin extends Plugin<WebSearchPluginConfig> {
|
|
|
66
222
|
* OBO client from the active execution context and reads the shared
|
|
67
223
|
* runtime config primed at setup.
|
|
68
224
|
*/
|
|
69
|
-
search:
|
|
70
|
-
|
|
225
|
+
search: (request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> =>
|
|
226
|
+
this.search(request, signal),
|
|
71
227
|
/**
|
|
72
228
|
* Fetch one URL directly (bypassing the agent tool). Enforces the
|
|
73
|
-
* configured
|
|
229
|
+
* configured URL policy. Reads the shared runtime config.
|
|
74
230
|
*/
|
|
75
|
-
fetch: (request: WebFetchRequest): Promise<WebFetchResult> =>
|
|
76
|
-
|
|
231
|
+
fetch: (request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult> =>
|
|
232
|
+
this.fetch(request, signal),
|
|
77
233
|
};
|
|
78
234
|
}
|
|
235
|
+
|
|
236
|
+
private async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
|
|
237
|
+
return runWebSearch(
|
|
238
|
+
request,
|
|
239
|
+
getWebSearchRuntime().config,
|
|
240
|
+
await resolveWebSearchContext(),
|
|
241
|
+
signal,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult> {
|
|
246
|
+
return runWebFetch(request, getWebSearchRuntime().config, signal);
|
|
247
|
+
}
|
|
79
248
|
}
|
|
80
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Register the web-search runtime with AppKit.
|
|
252
|
+
*
|
|
253
|
+
* @example
|
|
254
|
+
* ```ts
|
|
255
|
+
* import { createApp, server } from "@databricks/appkit";
|
|
256
|
+
* import { plugin as webSearchPlugin, tool as webTool } from "@dbx-tools/appkit-web-search";
|
|
257
|
+
* import { agents, plugin as mastraPlugin } from "@dbx-tools/appkit-mastra";
|
|
258
|
+
*
|
|
259
|
+
* const researcher = agents.createAgent({
|
|
260
|
+
* instructions: "Research questions with web_search, then read sources with web_fetch.",
|
|
261
|
+
* tools: () => ({ web_search: webTool.webSearchTool(), web_fetch: webTool.webFetchTool() }),
|
|
262
|
+
* });
|
|
263
|
+
*
|
|
264
|
+
* await createApp({
|
|
265
|
+
* plugins: [
|
|
266
|
+
* server(),
|
|
267
|
+
* webSearchPlugin.webSearch({ model: "gemini" }),
|
|
268
|
+
* mastraPlugin.mastra({ agents: researcher }),
|
|
269
|
+
* ],
|
|
270
|
+
* });
|
|
271
|
+
* ```
|
|
272
|
+
*/
|
|
81
273
|
export const webSearch = toPlugin(WebSearchPlugin);
|
package/src/provider.ts
CHANGED
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
* @module
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
+
import { ValidationError } from "@databricks/appkit";
|
|
27
|
+
import { z } from "zod";
|
|
28
|
+
|
|
26
29
|
/** A web-search-capable model provider family. */
|
|
27
30
|
export type WebSearchProvider = "openai" | "gemini";
|
|
28
31
|
|
|
@@ -70,21 +73,41 @@ export function supportsWebSearch(modelId: string): boolean {
|
|
|
70
73
|
return detectWebSearchProvider(modelId) !== null;
|
|
71
74
|
}
|
|
72
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Runtime shape of one entry in the operator override map. The map arrives as
|
|
78
|
+
* parsed JSON (config or `WEB_SEARCH_TOOLS`), so it is validated rather than
|
|
79
|
+
* asserted.
|
|
80
|
+
*/
|
|
81
|
+
const providerOverrideSchema = z.object({
|
|
82
|
+
api: z.enum(["responses", "chat"]).optional(),
|
|
83
|
+
tool: z.record(z.string(), z.unknown()).optional(),
|
|
84
|
+
});
|
|
85
|
+
|
|
73
86
|
/**
|
|
74
87
|
* Resolve the effective {@link WebSearchProviderSpec} for a provider: the
|
|
75
88
|
* built-in default, with any operator override (the `webSearchTools` map,
|
|
76
89
|
* keyed by provider) shallow-merged over it. An override may replace just the
|
|
77
|
-
* `tool` (the common case - a new tool type) or also the `api`.
|
|
90
|
+
* `tool` (the common case - a new tool type) or also the `api`. An override
|
|
91
|
+
* that is not one of those two fields is a deployment mistake that would
|
|
92
|
+
* otherwise be silently dropped, so it throws.
|
|
78
93
|
*/
|
|
79
94
|
export function webSearchToolSpec(
|
|
80
95
|
provider: WebSearchProvider,
|
|
81
96
|
overrides?: Record<string, unknown>,
|
|
82
97
|
): WebSearchProviderSpec {
|
|
83
98
|
const base = WEB_SEARCH_PROVIDERS[provider];
|
|
84
|
-
const
|
|
85
|
-
if (
|
|
99
|
+
const raw = overrides?.[provider];
|
|
100
|
+
if (raw === undefined) return base;
|
|
101
|
+
const parsed = providerOverrideSchema.safeParse(raw);
|
|
102
|
+
if (!parsed.success) {
|
|
103
|
+
throw ValidationError.invalidValue(
|
|
104
|
+
`webSearchTools.${provider}`,
|
|
105
|
+
raw,
|
|
106
|
+
'an object with an optional "api" ("responses" | "chat") and an optional "tool" object',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
86
109
|
return {
|
|
87
|
-
api:
|
|
88
|
-
tool:
|
|
110
|
+
api: parsed.data.api ?? base.api,
|
|
111
|
+
tool: parsed.data.tool ?? base.tool,
|
|
89
112
|
};
|
|
90
113
|
}
|
package/src/runtime.ts
CHANGED
|
@@ -5,23 +5,64 @@
|
|
|
5
5
|
* plugin at setup) primes it from the plugin's config; later callers (the
|
|
6
6
|
* tools' `execute`) reuse it.
|
|
7
7
|
*
|
|
8
|
+
* The runtime also carries the {@link WebSearchExecutor} every outbound call
|
|
9
|
+
* runs through. The plugin installs its own `execute()` there at setup, which
|
|
10
|
+
* is how the tools - plain functions with no plugin instance in scope - still
|
|
11
|
+
* get AppKit's cache / retry / timeout / telemetry chain. Without a
|
|
12
|
+
* registered plugin (a direct call from a script or a test) the calls still
|
|
13
|
+
* run, just without interceptors.
|
|
14
|
+
*
|
|
8
15
|
* Unlike the email runtime there is no connection to pool - the backend is
|
|
9
|
-
* stateless HTTP per call - so the runtime holds only the resolved config
|
|
16
|
+
* stateless HTTP per call - so the runtime holds only the resolved config and
|
|
17
|
+
* that executor.
|
|
10
18
|
*
|
|
11
19
|
* @module
|
|
12
20
|
*/
|
|
13
21
|
|
|
22
|
+
import { AppKitError, ExecutionError, type ExecutionResult } from "@databricks/appkit";
|
|
23
|
+
import { async, error, log } from "@dbx-tools/shared-core";
|
|
14
24
|
import {
|
|
15
25
|
resolveWebSearchConfig,
|
|
16
26
|
type ResolvedWebSearchConfig,
|
|
17
27
|
type WebSearchPluginConfig,
|
|
18
28
|
} from "./config";
|
|
29
|
+
import type { WebSearchExecutionSettings } from "./defaults";
|
|
30
|
+
|
|
31
|
+
const logger = log.logger("web-search/runtime");
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Runs one outbound call through AppKit's interceptor chain. Matches
|
|
35
|
+
* `Plugin.execute()`, which never throws: a failure comes back as
|
|
36
|
+
* `{ ok: false }`.
|
|
37
|
+
*/
|
|
38
|
+
export type WebSearchExecutor = <T>(
|
|
39
|
+
fn: (signal?: AbortSignal) => Promise<T>,
|
|
40
|
+
settings: WebSearchExecutionSettings,
|
|
41
|
+
) => Promise<ExecutionResult<T>>;
|
|
19
42
|
|
|
20
|
-
/** The shared resolved config. */
|
|
43
|
+
/** The shared resolved config plus the executor outbound calls run through. */
|
|
21
44
|
export interface WebSearchRuntime {
|
|
22
45
|
config: ResolvedWebSearchConfig;
|
|
46
|
+
execute: WebSearchExecutor;
|
|
23
47
|
}
|
|
24
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Executor used until (or unless) the plugin installs its own: run the call
|
|
51
|
+
* directly, mapping a throw onto the same {@link ExecutionResult} shape so
|
|
52
|
+
* call sites branch on `ok` either way.
|
|
53
|
+
*/
|
|
54
|
+
const directExecute: WebSearchExecutor = async (fn) => {
|
|
55
|
+
try {
|
|
56
|
+
return { ok: true, data: await fn() };
|
|
57
|
+
} catch (err) {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
status: err instanceof AppKitError ? err.statusCode : 500,
|
|
61
|
+
message: error.errorMessage(err),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
25
66
|
let runtime: WebSearchRuntime | undefined;
|
|
26
67
|
|
|
27
68
|
/**
|
|
@@ -33,12 +74,55 @@ let runtime: WebSearchRuntime | undefined;
|
|
|
33
74
|
*/
|
|
34
75
|
export function getWebSearchRuntime(overrides?: WebSearchPluginConfig): WebSearchRuntime {
|
|
35
76
|
if (!runtime) {
|
|
36
|
-
runtime = { config: resolveWebSearchConfig(overrides) };
|
|
77
|
+
runtime = { config: resolveWebSearchConfig(overrides), execute: directExecute };
|
|
37
78
|
}
|
|
38
79
|
return runtime;
|
|
39
80
|
}
|
|
40
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Install the executor outbound calls run through. The plugin calls this at
|
|
84
|
+
* setup with its own `execute()`; a second call replaces the previous one, so
|
|
85
|
+
* a re-registered plugin does not leave the tools bound to a dead instance.
|
|
86
|
+
*/
|
|
87
|
+
export function setWebSearchExecutor(execute: WebSearchExecutor): void {
|
|
88
|
+
getWebSearchRuntime().execute = execute;
|
|
89
|
+
}
|
|
90
|
+
|
|
41
91
|
/** Drop the memoized runtime so the next {@link getWebSearchRuntime} rebuilds it. */
|
|
42
92
|
export function resetWebSearchRuntime(): void {
|
|
43
93
|
runtime = undefined;
|
|
44
94
|
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Run one idempotent read through the shared executor and unwrap it.
|
|
98
|
+
*
|
|
99
|
+
* `execute()` never throws, so a failed call arrives as `{ ok: false }` with
|
|
100
|
+
* a status the interceptors already sanitized; it is logged here and re-raised
|
|
101
|
+
* as a stable {@link ExecutionError} so an upstream message never becomes the
|
|
102
|
+
* caller's error text. `signal` is the caller's own cancellation (an agent
|
|
103
|
+
* run, a request teardown); it is merged with the signal the timeout
|
|
104
|
+
* interceptor supplies so either one unwinds the I/O.
|
|
105
|
+
*/
|
|
106
|
+
export async function executeRead<T>(
|
|
107
|
+
operation: string,
|
|
108
|
+
settings: WebSearchExecutionSettings,
|
|
109
|
+
fn: (signal?: AbortSignal) => Promise<T>,
|
|
110
|
+
signal?: AbortSignal,
|
|
111
|
+
): Promise<T> {
|
|
112
|
+
const { execute } = getWebSearchRuntime();
|
|
113
|
+
const result = await execute(
|
|
114
|
+
(executeSignal) => fn(async.combineAbortSignals(executeSignal, signal)),
|
|
115
|
+
settings,
|
|
116
|
+
);
|
|
117
|
+
if (result.ok) return result.data;
|
|
118
|
+
// A caller that cancelled is not a failure worth reporting as one.
|
|
119
|
+
if (signal?.aborted) throw ExecutionError.canceled();
|
|
120
|
+
logger.warn("execution-failed", {
|
|
121
|
+
operation,
|
|
122
|
+
status: result.status,
|
|
123
|
+
error: result.message,
|
|
124
|
+
});
|
|
125
|
+
throw new ExecutionError(`web-search: ${operation} failed`, {
|
|
126
|
+
context: { operation, status: result.status },
|
|
127
|
+
});
|
|
128
|
+
}
|
package/src/schema.ts
CHANGED
|
@@ -20,6 +20,28 @@
|
|
|
20
20
|
import { string } from "@dbx-tools/shared-core";
|
|
21
21
|
import { z } from "zod";
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Description the model reads for `web_search`. Shared by the Mastra tool and
|
|
25
|
+
* the AppKit tool provider so both hosts describe the tool identically.
|
|
26
|
+
*/
|
|
27
|
+
export const WEB_SEARCH_TOOL_DESCRIPTION = string.toDescription(`
|
|
28
|
+
Search the web for current information and get an answer synthesized from
|
|
29
|
+
live results, with the sources it used. Pass a natural-language query;
|
|
30
|
+
the search runs inside a web-search-capable model (chosen independently
|
|
31
|
+
of your own model). Optionally pass a model name to use a specific
|
|
32
|
+
web-search model. Use it whenever a question needs up-to-date or external
|
|
33
|
+
information you don't already have.
|
|
34
|
+
`);
|
|
35
|
+
|
|
36
|
+
/** Description the model reads for `web_fetch`. Shared like {@link WEB_SEARCH_TOOL_DESCRIPTION}. */
|
|
37
|
+
export const WEB_FETCH_TOOL_DESCRIPTION = string.toDescription(`
|
|
38
|
+
Fetch a single web page and return its readable contents. Pass an
|
|
39
|
+
absolute URL (including https://); set format to "html" for raw markup
|
|
40
|
+
instead of extracted text. Use it to read a page returned by web_search
|
|
41
|
+
or provided by the user. Content is length-capped; fetching a URL outside
|
|
42
|
+
the configured allow-list is refused.
|
|
43
|
+
`);
|
|
44
|
+
|
|
23
45
|
/** Schema for the `web_search` tool input. */
|
|
24
46
|
export const webSearchRequestSchema = z.object({
|
|
25
47
|
query: z
|
package/src/scrape.ts
CHANGED
|
@@ -21,7 +21,9 @@
|
|
|
21
21
|
import { log } from "@dbx-tools/shared-core";
|
|
22
22
|
import { gotScraping } from "got-scraping";
|
|
23
23
|
import type { ResolvedWebSearchConfig } from "./config";
|
|
24
|
+
import { scrapeSearchExecuteDefaults, toCallSettings } from "./defaults";
|
|
24
25
|
import { htmlFragmentToText } from "./html-text";
|
|
26
|
+
import { executeRead } from "./runtime";
|
|
25
27
|
import type { WebSearchCitation, WebSearchRequest, WebSearchResult } from "./schema";
|
|
26
28
|
|
|
27
29
|
const logger = log.logger("web-search/scrape");
|
|
@@ -71,21 +73,40 @@ function parseDdgHtml(html: string): WebSearchCitation[] {
|
|
|
71
73
|
* Run a scraping search over DuckDuckGo. Returns the same
|
|
72
74
|
* {@link WebSearchResult} shape as the native path, with `model` set to
|
|
73
75
|
* `"scrape:duckduckgo"` so callers can tell how the result was produced.
|
|
74
|
-
* Citations are filtered through the configured URL allow-list.
|
|
76
|
+
* Citations are filtered through the configured URL allow-list. `signal`
|
|
77
|
+
* cancels the in-flight request.
|
|
75
78
|
*/
|
|
76
79
|
export async function runScrapeSearch(
|
|
77
80
|
request: WebSearchRequest,
|
|
78
81
|
config: ResolvedWebSearchConfig,
|
|
82
|
+
signal?: AbortSignal,
|
|
79
83
|
): Promise<WebSearchResult> {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
84
|
+
const page = await executeRead(
|
|
85
|
+
"scrape-search",
|
|
86
|
+
toCallSettings(scrapeSearchExecuteDefaults, config.timeoutMs, [
|
|
87
|
+
"web-search",
|
|
88
|
+
"scrape",
|
|
89
|
+
request.query,
|
|
90
|
+
]),
|
|
91
|
+
async (executeSignal): Promise<{ body: string; statusCode: number }> => {
|
|
92
|
+
const response = await gotScraping({
|
|
93
|
+
url: `${DDG_HTML_URL}?q=${encodeURIComponent(request.query)}`,
|
|
94
|
+
method: "GET",
|
|
95
|
+
// got's own timeout aborts the socket and reports which phase timed
|
|
96
|
+
// out; the interceptor timeout bounds the whole attempt around it.
|
|
97
|
+
timeout: { request: config.timeoutMs },
|
|
98
|
+
throwHttpErrors: false,
|
|
99
|
+
followRedirect: true,
|
|
100
|
+
...(executeSignal ? { signal: executeSignal } : {}),
|
|
101
|
+
});
|
|
102
|
+
return {
|
|
103
|
+
body: typeof response.body === "string" ? response.body : String(response.body ?? ""),
|
|
104
|
+
statusCode: response.statusCode,
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
signal,
|
|
108
|
+
);
|
|
109
|
+
const all = parseDdgHtml(page.body);
|
|
89
110
|
const permitted = all.filter((c) => config.allowList.allows(c.url));
|
|
90
111
|
const citations = permitted.slice(0, config.maxCitations);
|
|
91
112
|
const answer =
|
|
@@ -99,7 +120,7 @@ export async function runScrapeSearch(
|
|
|
99
120
|
query: request.query,
|
|
100
121
|
found: all.length,
|
|
101
122
|
returned: citations.length,
|
|
102
|
-
status:
|
|
123
|
+
status: page.statusCode,
|
|
103
124
|
});
|
|
104
125
|
return { query: request.query, answer, citations, model: "scrape:duckduckgo" };
|
|
105
126
|
}
|