@dbx-tools/search 0.6.9

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/plugin.ts ADDED
@@ -0,0 +1,582 @@
1
+ /**
2
+ * AppKit plugin (registered name: `search`) that turns a Databricks AI
3
+ * Search (Vector Search) index into a batteries-included search surface. It is
4
+ * the "shortcut" half of this package: register it with nothing but an index
5
+ * name and you get, all at once -
6
+ *
7
+ * - a `search` / `universal_search` / (opt-in) `add_documents` tool set for
8
+ * both Mastra and AppKit agents (autocomplete is a small-`limit` search);
9
+ * - HTTP routes under `/api/search` a browser search box calls directly
10
+ * (`POST /` search, `POST /universal` federated, `GET /indexes` catalogue,
11
+ * and `POST /documents` when writes are enabled);
12
+ * - a `clientConfig()` payload so a UI knows the indexes, default, and page
13
+ * size at boot with no round-trip;
14
+ * - `exports()` so app code can `appkit.search.search(...)` directly.
15
+ *
16
+ * Everything runs under the caller's OBO identity (routes wrap in `asUser`),
17
+ * so Unity Catalog ACLs on the index apply. Registering the plugin resolves
18
+ * and logs the effective config (default index, known indexes, page size,
19
+ * mode) so a misconfiguration shows up in the boot log rather than on the
20
+ * first search.
21
+ *
22
+ * @module
23
+ */
24
+
25
+ import {
26
+ lakebase,
27
+ Plugin,
28
+ ResourceType,
29
+ toPlugin,
30
+ type IAppRouter,
31
+ type PluginManifest,
32
+ type ResourceRequirement,
33
+ } from "@databricks/appkit";
34
+ import {
35
+ defineTool,
36
+ executeFromRegistry,
37
+ toolsFromRegistry,
38
+ type AgentToolDefinition,
39
+ type ToolProvider,
40
+ type ToolRegistry,
41
+ } from "@databricks/appkit/beta";
42
+ import { plugin as pluginLookup } from "@dbx-tools/appkit";
43
+ import { search as searchContract, type SearchClientConfig } from "@dbx-tools/shared-search";
44
+ import { error as errorUtil, log, string } from "@dbx-tools/shared-core";
45
+ import {
46
+ SEARCH_CONFIG_SCHEMA,
47
+ DATABRICKS_INDEX_ENV,
48
+ ENDPOINT_ENV,
49
+ INDEX_ENV,
50
+ resolveSearchConfig,
51
+ type SearchPluginConfig,
52
+ } from "./config.ts";
53
+ import { LakebaseSearchBackend } from "./lakebase.ts";
54
+ import { toCreateIndexOptions } from "./index-tools.ts";
55
+ import { toDocumentArray } from "./query.ts";
56
+ import { getSearchRuntime, resetSearchRuntime } from "./runtime.ts";
57
+ import {
58
+ ADD_DOCUMENTS_TOOL_DESCRIPTION,
59
+ CREATE_INDEX_TOOL_DESCRIPTION,
60
+ SEARCH_TOOL_DESCRIPTION,
61
+ SYNC_INDEX_TOOL_DESCRIPTION,
62
+ UNIVERSAL_SEARCH_TOOL_DESCRIPTION,
63
+ } from "./schema.ts";
64
+
65
+ const logger = log.logger("search");
66
+
67
+ /** Mount-relative route (under `/api/search`) for a single-index search. */
68
+ const SEARCH_ROUTE = "/";
69
+
70
+ /** Mount-relative route for a universal (federated) search across indexes. */
71
+ const UNIVERSAL_ROUTE = "/universal";
72
+
73
+ /** Mount-relative route serving the index catalogue a search box reads. */
74
+ const INDEXES_ROUTE = "/indexes";
75
+
76
+ /** Mount-relative route for adding documents to a direct-access index. */
77
+ const DOCUMENTS_ROUTE = "/documents";
78
+
79
+ /** Mount-relative route for creating a Vector Search index. */
80
+ const INDEX_ROUTE = "/index";
81
+
82
+ /** Mount-relative route for syncing a Delta Sync index from its source table. */
83
+ const INDEX_SYNC_ROUTE = "/index/sync";
84
+
85
+ /**
86
+ * The AI Search index resource. Declared optional because the plugin is happy
87
+ * with no default index (a caller can name one per request); it is promoted to
88
+ * required once a deployment pins one. `SELECT` is the permission a query needs.
89
+ */
90
+ const INDEX_RESOURCE = {
91
+ type: ResourceType.VECTOR_SEARCH_INDEX,
92
+ alias: "AI Search Index",
93
+ resourceKey: "search-index",
94
+ description:
95
+ "Databricks AI Search (Vector Search) index the app searches by default " +
96
+ "(catalog.schema.index). Optional: a request may name any index the caller can read.",
97
+ permission: "SELECT",
98
+ fields: {
99
+ name: {
100
+ env: INDEX_ENV,
101
+ description: `Default AI Search index. ${DATABRICKS_INDEX_ENV} is also honored.`,
102
+ discovery: {
103
+ type: "cli",
104
+ cliCommand:
105
+ "databricks vector-search-indexes list-indexes --endpoint-name <ENDPOINT> --output json",
106
+ selectField: ".name",
107
+ },
108
+ },
109
+ },
110
+ } satisfies Omit<ResourceRequirement, "required">;
111
+
112
+ /**
113
+ * AppKit plugin exposing AI Search as a search box, an agent tool set, and a
114
+ * direct API.
115
+ *
116
+ * @example
117
+ * ```ts
118
+ * import { createApp, server } from "@databricks/appkit";
119
+ * import { plugin as searchPlugin } from "@dbx-tools/search";
120
+ *
121
+ * await createApp({
122
+ * plugins: [
123
+ * server(),
124
+ * // zero-config: reads DATABRICKS_VECTOR_SEARCH_INDEX
125
+ * searchPlugin.search(),
126
+ * // or go deeper:
127
+ * // searchPlugin.search({
128
+ * // index: "main.support.docs",
129
+ * // indexes: ["main.support.docs", "main.catalog.products"],
130
+ * // columns: ["title", "url", "body"],
131
+ * // mode: "hybrid",
132
+ * // }),
133
+ * ],
134
+ * });
135
+ * ```
136
+ */
137
+ export class SearchPlugin extends Plugin<SearchPluginConfig> implements ToolProvider {
138
+ static manifest = {
139
+ name: "search",
140
+ displayName: "AI Search",
141
+ description:
142
+ "Search Databricks AI Search (Vector Search) indexes: agent tools, HTTP routes for a " +
143
+ "search box, universal search across indexes, and an opt-in document write surface.",
144
+ stability: "beta",
145
+ resources: {
146
+ required: [],
147
+ optional: [INDEX_RESOURCE],
148
+ },
149
+ config: { schema: SEARCH_CONFIG_SCHEMA },
150
+ } satisfies PluginManifest<"search">;
151
+
152
+ /** The base path AppKit mounts this plugin's routes under. */
153
+ private get basePath(): string {
154
+ return `/api/${SearchPlugin.manifest.name}`;
155
+ }
156
+
157
+ /**
158
+ * Promote the index to a required resource once a deployment pins a default,
159
+ * through plugin config or either environment name.
160
+ */
161
+ static getResourceRequirements(config: SearchPluginConfig): ResourceRequirement[] {
162
+ const pinned =
163
+ string.trimToNull(config.index) ??
164
+ string.trimToNull(process.env[INDEX_ENV]) ??
165
+ string.trimToNull(process.env[DATABRICKS_INDEX_ENV]);
166
+ return pinned === null ? [] : [{ ...INDEX_RESOURCE, required: true }];
167
+ }
168
+
169
+ /**
170
+ * The tools this plugin offers to an AppKit agent. `search` /
171
+ * `universal_search` are reads; `add_documents` / `create_index` /
172
+ * `sync_index` are only offered when the write surface is enabled. None is
173
+ * autoInheritable: every tool runs under the caller's identity, so it must be
174
+ * granted explicitly.
175
+ */
176
+ private get tools(): ToolRegistry {
177
+ const { config } = getSearchRuntime({ config: this.config });
178
+ const registry: ToolRegistry = {
179
+ search: defineTool({
180
+ description: SEARCH_TOOL_DESCRIPTION,
181
+ schema: searchContract.searchRequestSchema,
182
+ annotations: { effect: "read", requiresUserContext: true },
183
+ autoInheritable: false,
184
+ execute: async (args, signal) => this.runSearch(args, signal),
185
+ }),
186
+ universal_search: defineTool({
187
+ description: UNIVERSAL_SEARCH_TOOL_DESCRIPTION,
188
+ schema: searchContract.universalSearchRequestSchema,
189
+ annotations: { effect: "read", requiresUserContext: true },
190
+ autoInheritable: false,
191
+ execute: async (args, signal) => this.runUniversalSearch(args, signal),
192
+ }),
193
+ };
194
+ if (config.allowWrite) {
195
+ registry.add_documents = defineTool({
196
+ description: ADD_DOCUMENTS_TOOL_DESCRIPTION,
197
+ schema: searchContract.searchRequestSchema
198
+ .pick({ index: true })
199
+ .extend({ documents: searchContract.searchDocumentSchema.array() }),
200
+ annotations: { effect: "write", requiresUserContext: true },
201
+ autoInheritable: false,
202
+ execute: async (args, signal) => this.runAddDocuments(args, signal),
203
+ });
204
+ registry.create_index = defineTool({
205
+ description: CREATE_INDEX_TOOL_DESCRIPTION,
206
+ schema: searchContract.createIndexRequestSchema,
207
+ annotations: { effect: "write", requiresUserContext: true },
208
+ autoInheritable: false,
209
+ execute: async (args, signal) => this.runCreateIndex(args, signal),
210
+ });
211
+ registry.sync_index = defineTool({
212
+ description: SYNC_INDEX_TOOL_DESCRIPTION,
213
+ schema: searchContract.syncIndexRequestSchema,
214
+ annotations: { effect: "write", requiresUserContext: true },
215
+ autoInheritable: false,
216
+ execute: async (args, signal) => this.runSyncIndex(args, signal),
217
+ });
218
+ }
219
+ return registry;
220
+ }
221
+
222
+ /** Prime the shared runtime from config and log the effective policy at boot. */
223
+ override async setup(): Promise<void> {
224
+ // Choose a backend. Vector Search is primary; when NO Vector Search
225
+ // endpoint is configured but the AppKit `lakebase` plugin is registered,
226
+ // fall back to a Postgres full-text index. Both answer with the identical
227
+ // shape, so this choice is invisible to tools, routes, and the UI.
228
+ const lakebaseBackend = this.resolveLakebaseBackend();
229
+ // The runtime may already have been built (config-only) when `tools()` ran
230
+ // during registration; rebuild it so it carries the chosen backend.
231
+ resetSearchRuntime();
232
+ const { config } = getSearchRuntime({
233
+ config: this.config,
234
+ ...(lakebaseBackend ? { lakebase: lakebaseBackend } : {}),
235
+ });
236
+ logger.info("ready", {
237
+ backend: lakebaseBackend ? "lakebase" : "vector-search",
238
+ defaultIndex: config.defaultIndex ?? "(none - pass per request)",
239
+ indexes: config.indexes.map((i) => i.alias),
240
+ pageSize: config.pageSize,
241
+ mode: config.mode,
242
+ allowWrite: config.allowWrite,
243
+ basePath: this.basePath,
244
+ ensureOnSetup: config.ensureOnSetup
245
+ ? (config.ensureOnSetup.index ?? config.defaultIndex)
246
+ : "off",
247
+ });
248
+ // Provision a real index in the BACKGROUND so a slow first-time endpoint or
249
+ // index build never blocks the server from coming up.
250
+ if (config.ensureOnSetup) void this.runEnsureOnSetup(config);
251
+ }
252
+
253
+ /**
254
+ * Build the Lakebase FALLBACK backend, or return `undefined` when Vector
255
+ * Search should be used. Falls back only when there is NO Vector Search
256
+ * endpoint configured (plugin config or `SEARCH_ENDPOINT` /
257
+ * `DATABRICKS_VECTOR_SEARCH_ENDPOINT`) AND the sibling AppKit `lakebase`
258
+ * plugin is registered. The pg pool is built from that plugin's
259
+ * service-principal config exactly like `@dbx-tools/appkit-mastra`'s memory
260
+ * pool - no auth is re-implemented here.
261
+ */
262
+ private resolveLakebaseBackend(): LakebaseSearchBackend | undefined {
263
+ const config = resolveSearchConfig(this.config);
264
+ const hasEndpoint =
265
+ config.endpoint !== undefined ||
266
+ string.trimToNull(process.env[ENDPOINT_ENV]) !== null ||
267
+ string.trimToNull(process.env.DATABRICKS_VECTOR_SEARCH_ENDPOINT) !== null;
268
+ if (hasEndpoint) return undefined;
269
+ const lake = pluginLookup.instance(this.context, lakebase);
270
+ if (!lake) return undefined;
271
+ logger.info("backend-lakebase", {
272
+ reason: "no Vector Search endpoint configured; using the Lakebase full-text fallback",
273
+ });
274
+ // `getPgConfig()` must be read OUTSIDE any asUser scope (as it is here at
275
+ // setup) so it carries the SP connection target + token-refresh callback.
276
+ return new LakebaseSearchBackend(() => lake.exports().getPgConfig());
277
+ }
278
+
279
+ /**
280
+ * Ensure the endpoint + index exist and seed them, honoring `ensureOnSetup`.
281
+ * Uses boot-time SDK auth (env / config profile) via the client's
282
+ * out-of-request fallback. Failures are logged, never thrown - a search app
283
+ * should still start even if provisioning is slow or a permission is missing.
284
+ */
285
+ private async runEnsureOnSetup(
286
+ config: ReturnType<typeof getSearchRuntime>["config"],
287
+ ): Promise<void> {
288
+ const spec = config.ensureOnSetup;
289
+ if (!spec) return;
290
+ const index = string.trimToNull(spec.index) ?? config.defaultIndex;
291
+ if (!index) {
292
+ logger.warn("ensure-skipped", {
293
+ reason: "no index name (set `index` or `ensureOnSetup.index`)",
294
+ });
295
+ return;
296
+ }
297
+ const documents = spec.documents ?? [];
298
+ // Infer a managed-direct-access schema from the first seed row when not given.
299
+ const schema =
300
+ spec.schema ??
301
+ (documents.length > 0 && !spec.sourceTable
302
+ ? this.inferSchema(documents[0], spec.primaryKey ?? "id", spec.textColumn ?? "text")
303
+ : undefined);
304
+ try {
305
+ logger.info("ensure-start", { index });
306
+ const { client } = getSearchRuntime();
307
+ const info = await client.provision(index, {
308
+ ...(spec.endpoint ? { endpoint: spec.endpoint } : {}),
309
+ ...(spec.primaryKey ? { primaryKey: spec.primaryKey } : {}),
310
+ ...(spec.textColumn ? { embeddingSourceColumn: spec.textColumn } : {}),
311
+ ...(spec.embeddingModel ? { embeddingModel: spec.embeddingModel } : {}),
312
+ ...(spec.sourceTable ? { sourceTable: spec.sourceTable } : {}),
313
+ ...(schema ? { schema } : {}),
314
+ ...(spec.timeoutMs ? { timeoutMs: spec.timeoutMs } : {}),
315
+ ...(documents.length > 0 ? { seed: documents } : {}),
316
+ });
317
+ logger.info("ensure-ready", {
318
+ index: info.name,
319
+ ready: info.ready,
320
+ rowCount: info.rowCount ?? 0,
321
+ });
322
+ } catch (cause) {
323
+ logger.warn("ensure-failed", { index, message: errorUtil.errorMessage(cause) });
324
+ }
325
+ }
326
+
327
+ /** Build a Vector Search `schema_json` map from a seed document's value types. */
328
+ private inferSchema(
329
+ doc: Record<string, unknown>,
330
+ primaryKey: string,
331
+ textColumn: string,
332
+ ): Record<string, string> {
333
+ const schema: Record<string, string> = { [primaryKey]: "string", [textColumn]: "string" };
334
+ for (const [key, value] of Object.entries(doc)) {
335
+ if (key in schema) continue;
336
+ schema[key] =
337
+ typeof value === "number"
338
+ ? Number.isInteger(value)
339
+ ? "int"
340
+ : "double"
341
+ : typeof value === "boolean"
342
+ ? "boolean"
343
+ : "string";
344
+ }
345
+ return schema;
346
+ }
347
+
348
+ /** Drop the shared runtime so a restarted app re-resolves config. */
349
+ async shutdown(): Promise<void> {
350
+ resetSearchRuntime();
351
+ }
352
+
353
+ override abortActiveOperations(): void {
354
+ super.abortActiveOperations();
355
+ void this.shutdown();
356
+ }
357
+
358
+ /**
359
+ * Mount the search routes under `/api/search`. Each is wrapped in
360
+ * `asUser(req)` so the query runs as the requesting user and the index's
361
+ * Unity Catalog ACLs apply. `GET /indexes` needs no user scope - it just
362
+ * echoes the configured catalogue.
363
+ */
364
+ override injectRoutes(router: IAppRouter): void {
365
+ this.route(router, {
366
+ name: "search",
367
+ method: "post",
368
+ path: SEARCH_ROUTE,
369
+ handler: async (req, res) => {
370
+ await this.respond(res, "search", () => {
371
+ const request = searchContract.searchRequestSchema.parse(req.body ?? {});
372
+ return this.asUser(req).runSearch(request);
373
+ });
374
+ },
375
+ });
376
+ this.route(router, {
377
+ name: "universalSearch",
378
+ method: "post",
379
+ path: UNIVERSAL_ROUTE,
380
+ handler: async (req, res) => {
381
+ await this.respond(res, "universalSearch", () => {
382
+ const request = searchContract.universalSearchRequestSchema.parse(req.body ?? {});
383
+ return this.asUser(req).runUniversalSearch(request);
384
+ });
385
+ },
386
+ });
387
+ this.route(router, {
388
+ name: "indexes",
389
+ method: "get",
390
+ path: INDEXES_ROUTE,
391
+ handler: async (_req, res) => {
392
+ res.json(this.clientConfig());
393
+ },
394
+ });
395
+ this.route(router, {
396
+ name: "documents",
397
+ method: "post",
398
+ path: DOCUMENTS_ROUTE,
399
+ handler: async (req, res) => {
400
+ const { config } = getSearchRuntime();
401
+ if (!config.allowWrite) {
402
+ res.status(403).json({ error: "the document write surface is disabled" });
403
+ return;
404
+ }
405
+ await this.respond(res, "addDocuments", () =>
406
+ this.asUser(req).runAddDocuments(req.body ?? {}),
407
+ );
408
+ },
409
+ });
410
+ this.route(router, {
411
+ name: "createIndex",
412
+ method: "post",
413
+ path: INDEX_ROUTE,
414
+ handler: async (req, res) => {
415
+ const { config } = getSearchRuntime();
416
+ if (!config.allowWrite) {
417
+ res.status(403).json({ error: "the index write surface is disabled" });
418
+ return;
419
+ }
420
+ await this.respond(res, "createIndex", () =>
421
+ this.asUser(req).runCreateIndex(req.body ?? {}),
422
+ );
423
+ },
424
+ });
425
+ this.route(router, {
426
+ name: "syncIndex",
427
+ method: "post",
428
+ path: INDEX_SYNC_ROUTE,
429
+ handler: async (req, res) => {
430
+ const { config } = getSearchRuntime();
431
+ if (!config.allowWrite) {
432
+ res.status(403).json({ error: "the index write surface is disabled" });
433
+ return;
434
+ }
435
+ await this.respond(res, "syncIndex", () => this.asUser(req).runSyncIndex(req.body ?? {}));
436
+ },
437
+ });
438
+ }
439
+
440
+ /**
441
+ * Run a route body, turning a failure into a JSON error response.
442
+ *
443
+ * AppKit's `route()` registers the handler as-is, so a rejection escapes as
444
+ * an unhandled promise rejection and takes the whole process down rather than
445
+ * failing the one request. Every route here goes through this, so a bad index
446
+ * name or a Vector Search API error returns 500 with a message instead of
447
+ * killing the server.
448
+ */
449
+ private async respond(
450
+ res: Parameters<Parameters<IAppRouter["post"]>[1]>[1],
451
+ operation: string,
452
+ run: () => Promise<unknown>,
453
+ ): Promise<void> {
454
+ try {
455
+ res.json(await run());
456
+ } catch (cause) {
457
+ const message = errorUtil.errorMessage(cause);
458
+ logger.warn("route-failed", { operation, message });
459
+ res.status(500).json({ error: message });
460
+ }
461
+ }
462
+
463
+ /** Surface the index catalogue + defaults so a search box needs no round-trip. */
464
+ override clientConfig(): Record<string, unknown> {
465
+ const { config } = getSearchRuntime({ config: this.config });
466
+ const payload: SearchClientConfig = {
467
+ indexes: config.indexes.map((index) => ({
468
+ name: index.name,
469
+ alias: index.alias,
470
+ ...(index.primaryKey ? { primaryKey: index.primaryKey } : {}),
471
+ ...(index.columns ? { columns: index.columns } : {}),
472
+ ...(index.name === config.defaultIndex ? { isDefault: true } : {}),
473
+ })),
474
+ ...(config.defaultIndex ? { defaultIndex: config.defaultIndex } : {}),
475
+ pageSize: config.pageSize,
476
+ basePath: this.basePath,
477
+ };
478
+ return payload as unknown as Record<string, unknown>;
479
+ }
480
+
481
+ /** AppKit `ToolProvider`: the tool definitions offered to an agent. */
482
+ getAgentTools(): AgentToolDefinition[] {
483
+ return toolsFromRegistry(this.tools);
484
+ }
485
+
486
+ /** AppKit `ToolProvider`: run one tool call, validating input against its schema. */
487
+ async executeAgentTool(name: string, args: unknown, signal?: AbortSignal): Promise<unknown> {
488
+ return executeFromRegistry(this.tools, name, args, signal);
489
+ }
490
+
491
+ override exports() {
492
+ return {
493
+ /** Search one index (default when omitted). Runs as the current context user. */
494
+ search: (request: searchContract.SearchRequest, signal?: AbortSignal) =>
495
+ this.runSearch(request, signal),
496
+ /** Search across every configured index and merge the hits. */
497
+ universalSearch: (request: searchContract.UniversalSearchRequest, signal?: AbortSignal) =>
498
+ this.runUniversalSearch(request, signal),
499
+ /** Add or update documents in a direct-access index (throws when writes are disabled). */
500
+ addDocuments: (request: { index?: string; documents: unknown }, signal?: AbortSignal) =>
501
+ this.runAddDocuments(request, signal),
502
+ /** Create a Vector Search index (throws when writes are disabled). */
503
+ createIndex: (request: searchContract.CreateIndexRequest, signal?: AbortSignal) =>
504
+ this.runCreateIndex(request, signal),
505
+ /** Sync a Delta Sync index from its source table (throws when writes are disabled). */
506
+ syncIndex: (request: searchContract.SyncIndexRequest, signal?: AbortSignal) =>
507
+ this.runSyncIndex(request, signal),
508
+ };
509
+ }
510
+
511
+ private async runSearch(args: unknown, signal?: AbortSignal) {
512
+ const request = searchContract.searchRequestSchema.parse(args);
513
+ const { client } = getSearchRuntime();
514
+ return client.search(request.query, {
515
+ ...(request.index ? { index: request.index } : {}),
516
+ ...(request.limit ? { limit: request.limit } : {}),
517
+ ...(request.mode ? { mode: request.mode } : {}),
518
+ ...(request.columns ? { columns: request.columns } : {}),
519
+ ...(request.filter ? { filter: request.filter } : {}),
520
+ ...(request.scoreThreshold !== undefined ? { scoreThreshold: request.scoreThreshold } : {}),
521
+ ...(signal ? { signal } : {}),
522
+ });
523
+ }
524
+
525
+ private async runUniversalSearch(args: unknown, signal?: AbortSignal) {
526
+ const request = searchContract.universalSearchRequestSchema.parse(args);
527
+ const { client } = getSearchRuntime();
528
+ return client.universalSearch(request.query, {
529
+ ...(request.indexes ? { indexes: request.indexes } : {}),
530
+ ...(request.limit ? { limit: request.limit } : {}),
531
+ ...(request.mode ? { mode: request.mode } : {}),
532
+ ...(signal ? { signal } : {}),
533
+ });
534
+ }
535
+
536
+ private async runAddDocuments(args: unknown, signal?: AbortSignal) {
537
+ const record = (args ?? {}) as { index?: string; documents: unknown };
538
+ const { client, config } = getSearchRuntime();
539
+ const documents = toDocumentArray(record.documents);
540
+ const index = record.index ?? config.defaultIndex ?? "";
541
+ return client.addDocuments(index, documents, signal);
542
+ }
543
+
544
+ private async runCreateIndex(args: unknown, signal?: AbortSignal) {
545
+ const request = searchContract.createIndexRequestSchema.parse(args);
546
+ const { client } = getSearchRuntime();
547
+ return client.createIndex(request.name, toCreateIndexOptions(request, signal));
548
+ }
549
+
550
+ private async runSyncIndex(args: unknown, signal?: AbortSignal) {
551
+ const request = searchContract.syncIndexRequestSchema.parse(args);
552
+ const { client, config } = getSearchRuntime();
553
+ const index = request.index ?? config.defaultIndex ?? "";
554
+ await client.syncIndex(index, signal);
555
+ return { index, synced: true };
556
+ }
557
+ }
558
+
559
+ /**
560
+ * Register the AI Search plugin with AppKit.
561
+ *
562
+ * @example
563
+ * ```ts
564
+ * import { createApp, server } from "@databricks/appkit";
565
+ * import { plugin as searchPlugin, tool as searchToolModule } from "@dbx-tools/search";
566
+ * import { agents, plugin as mastraPlugin } from "@dbx-tools/appkit-mastra";
567
+ *
568
+ * const support = agents.createAgent({
569
+ * instructions: "Answer from the docs; use `search` to find them.",
570
+ * tools: () => ({ search: searchToolModule.searchTool() }),
571
+ * });
572
+ *
573
+ * await createApp({
574
+ * plugins: [
575
+ * server(),
576
+ * searchPlugin.search({ index: "main.support.docs" }),
577
+ * mastraPlugin.mastra({ agents: support }),
578
+ * ],
579
+ * });
580
+ * ```
581
+ */
582
+ export const search = toPlugin(SearchPlugin);