From fa827c149e5920c8c9fcc43eb3be4f248f8485b4 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Fri, 26 Jun 2026 18:56:05 +0530 Subject: [PATCH 1/4] feat(semantic-search): add cloudflare ai search backend --- apps/host-cloudflare/src/app.ts | 1 + apps/host-cloudflare/src/config.ts | 7 +- apps/host-cloudflare/src/execution.ts | 10 +- apps/host-cloudflare/src/plugins.ts | 16 +- apps/host-cloudflare/wrangler.jsonc | 4 + .../plugins/semantic-search/src/api/group.ts | 7 + .../semantic-search/src/sdk/ai-search.test.ts | 222 ++++++++ .../semantic-search/src/sdk/ai-search.ts | 479 ++++++++++++++++++ .../semantic-search/src/sdk/collections.ts | 23 + .../semantic-search/src/sdk/documents.ts | 94 ++++ .../plugins/semantic-search/src/sdk/index.ts | 18 + .../src/sdk/tool-search-backend.ts | 7 + 12 files changed, 881 insertions(+), 7 deletions(-) create mode 100644 packages/plugins/semantic-search/src/sdk/ai-search.test.ts create mode 100644 packages/plugins/semantic-search/src/sdk/ai-search.ts diff --git a/apps/host-cloudflare/src/app.ts b/apps/host-cloudflare/src/app.ts index 7213a88ec..08efc41a1 100644 --- a/apps/host-cloudflare/src/app.ts +++ b/apps/host-cloudflare/src/app.ts @@ -40,6 +40,7 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => { const plugins = makeCloudflarePlugins( config.secretKey, env.ANALYTICS, + config.aiSearch, env.VECTORIZE, config.geminiApiKey, config.organizationId, diff --git a/apps/host-cloudflare/src/config.ts b/apps/host-cloudflare/src/config.ts index e6074189c..6dc9e497b 100644 --- a/apps/host-cloudflare/src/config.ts +++ b/apps/host-cloudflare/src/config.ts @@ -5,7 +5,7 @@ import type { R2Bucket, } from "@cloudflare/workers-types"; import type { AnalyticsEngineDataset } from "@executor-js/plugin-execution-metrics/cloudflare"; -import type { VectorizeIndex } from "@executor-js/plugin-semantic-search"; +import type { AiSearchInstance, VectorizeIndex } from "@executor-js/plugin-semantic-search"; import { isValidOrgSlug } from "@executor-js/api"; import { missingPublicOriginWarning, resolvePublicOrigin } from "@executor-js/sdk/public-origin"; @@ -34,6 +34,8 @@ export interface CloudflareEnv { * bound (uncomment `analytics_engine_datasets` in wrangler.jsonc), each * finished execution/tool call writes a data point; absent, metrics are off. */ readonly ANALYTICS?: AnalyticsEngineDataset; + /** AI Search instance binding - preferred semantic `tools.search` backend. */ + readonly AI_SEARCH?: AiSearchInstance; /** Vectorize index binding — opt-in semantic `tools.search`. When bound (add a * `vectorize` binding in wrangler.jsonc) the semantic-search plugin embeds * the tool catalog and answers `tools.search` from it; absent, the engine @@ -86,6 +88,8 @@ export interface CloudflareConfig { /** URL slug for org-prefixed console paths (`//policies`). */ readonly organizationSlug: string; readonly secretKey: string; + /** AI Search instance binding for semantic `tools.search`. */ + readonly aiSearch?: AiSearchInstance; /** Gemini API key for the Vectorize search embeddings (a `wrangler secret`). * Unset → vectorize search is inert. */ readonly geminiApiKey?: string; @@ -146,6 +150,7 @@ export const loadConfig = (env: CloudflareEnv): CloudflareConfig => { organizationName: env.SELF_HOSTED_ORG_NAME ?? "Default", organizationSlug: resolveOrgSlug(env.SELF_HOSTED_ORG_SLUG), secretKey, + aiSearch: env.AI_SEARCH, geminiApiKey: env.GEMINI_API_KEY?.trim() || undefined, allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true", // Pinned origin via the shared resolver. A Worker receives no PaaS platform diff --git a/apps/host-cloudflare/src/execution.ts b/apps/host-cloudflare/src/execution.ts index 06bfec2aa..940c4b4de 100644 --- a/apps/host-cloudflare/src/execution.ts +++ b/apps/host-cloudflare/src/execution.ts @@ -38,7 +38,15 @@ export const makeCloudflarePluginsProvider = ( config: CloudflareConfig, ): Layer.Layer => Layer.succeed(PluginsProvider)({ - plugins: () => makeCloudflarePlugins(config.secretKey), + plugins: () => + makeCloudflarePlugins( + config.secretKey, + undefined, + config.aiSearch, + undefined, + undefined, + config.organizationId, + ), }); export const makeCloudflareHostConfig = (config: CloudflareConfig): Layer.Layer => diff --git a/apps/host-cloudflare/src/plugins.ts b/apps/host-cloudflare/src/plugins.ts index 194115e6f..e2ca7b72d 100644 --- a/apps/host-cloudflare/src/plugins.ts +++ b/apps/host-cloudflare/src/plugins.ts @@ -13,9 +13,11 @@ import { noopExecutionObserver } from "@executor-js/sdk"; import { serviceTokensPlugin } from "@executor-js/plugin-service-tokens/server"; import { semanticSearchHttpPlugin } from "@executor-js/plugin-semantic-search/api"; import { + makeAiSearchToolSearchBackend, makeVectorizeStore, ToolSearchBackend, withCloudflareLimits, + type AiSearchInstance, type VectorizeIndex, } from "@executor-js/plugin-semantic-search"; @@ -39,21 +41,25 @@ import { // // Semantic search follows the same opt-in-by-binding shape: the plugin is // always in the tuple (its reindex route keeps the API shape stable), but it is -// inert — the engine keeps its lexical `tools.search` — until BOTH a `vectorize` -// binding and the `GEMINI_API_KEY` secret are present. To enable: create a -// Vectorize index + add the binding in wrangler.jsonc and set the secret. +// inert until a search backend binding is present. Prefer Cloudflare AI Search +// when bound; otherwise fall back to the Vectorize + Gemini backend. // --------------------------------------------------------------------------- export const makeCloudflarePlugins = ( secretKey: string, analytics?: AnalyticsEngineDataset, + aiSearch?: AiSearchInstance, vectorize?: VectorizeIndex, geminiApiKey?: string, searchNamespace?: string, ) => { const store = vectorize ? withCloudflareLimits(makeVectorizeStore(vectorize)) : undefined; - const semanticSearchBackend = - store && geminiApiKey + const semanticSearchBackend = aiSearch + ? makeAiSearchToolSearchBackend({ + aiSearch, + namespace: searchNamespace, + }) + : store && geminiApiKey ? ToolSearchBackend.vector({ store, geminiApiKey, diff --git a/apps/host-cloudflare/wrangler.jsonc b/apps/host-cloudflare/wrangler.jsonc index f4ec27480..03bdc71bd 100644 --- a/apps/host-cloudflare/wrangler.jsonc +++ b/apps/host-cloudflare/wrangler.jsonc @@ -46,6 +46,10 @@ // "analytics_engine_datasets": [ // { "binding": "ANALYTICS", "dataset": "executor_executions" }, // ], + // Cloudflare AI Search is the preferred backend for semantic `tools.search`. + // Uncomment after creating the instance, otherwise deploy will fail because + // the binding target is absent. + // "ai_search": [{ "binding": "AI_SEARCH", "instance_name": "executor-tool-search" }], // The MCP session Durable Object: one addressable isolate per MCP session (the // DO id IS the session id) so a session survives across the Worker's stateless // isolates — without it, `tools/list` after `initialize` can land on a fresh diff --git a/packages/plugins/semantic-search/src/api/group.ts b/packages/plugins/semantic-search/src/api/group.ts index 21b47ffa8..af47fa55d 100644 --- a/packages/plugins/semantic-search/src/api/group.ts +++ b/packages/plugins/semantic-search/src/api/group.ts @@ -57,6 +57,13 @@ export const StatusResponse = Schema.Struct({ namespace: Schema.String, indexed: Schema.Number, lexical: Schema.NullOr(Schema.Number), + queued: Schema.optional(Schema.Number), + running: Schema.optional(Schema.Number), + completed: Schema.optional(Schema.Number), + error: Schema.optional(Schema.Number), + skipped: Schema.optional(Schema.Number), + outdated: Schema.optional(Schema.Number), + lastActivity: Schema.optional(Schema.String), }); export type SearchResultItemType = typeof SearchResultItem.Type; diff --git a/packages/plugins/semantic-search/src/sdk/ai-search.test.ts b/packages/plugins/semantic-search/src/sdk/ai-search.test.ts new file mode 100644 index 000000000..e61b97351 --- /dev/null +++ b/packages/plugins/semantic-search/src/sdk/ai-search.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from "@effect/vitest"; +import { type PluginStorageCollectionFacade, type PluginStorageEntry } from "@executor-js/sdk/core"; +import { Effect } from "effect"; + +import { + makeAiSearchToolDiscoveryProvider, + reindexAiSearch, + type AiSearchInstance, +} from "./ai-search"; +import { type aiSearchItems, type AiSearchItemRow } from "./collections"; + +type ItemsCollection = PluginStorageCollectionFacade; + +const fixedDate = new Date("2026-06-25T00:00:00.000Z"); + +const githubRow: PluginStorageEntry = { + id: "entry:github.default.main.repos.create", + owner: "org", + pluginId: "semantic-search", + collection: "aiSearchItems", + key: "github.default.main.repos.create", + data: { + path: "github.default.main.repos.create", + key: "github.repos.create.md", + itemId: "item:github.repos.create.md", + name: "repos.create", + description: "Create a repository", + integration: "github", + fingerprint: "github-fingerprint", + status: "queued", + updatedAt: "2026-06-25T00:00:00.000Z", + }, + createdAt: fixedDate, + updatedAt: fixedDate, +}; + +const unusedEffect = (): Effect.Effect => + Effect.sync(() => expect.unreachable("Unexpected plugin storage test call")); + +const makeItemsCollection = (overrides: Partial): ItemsCollection => ({ + get: () => unusedEffect(), + getMany: () => unusedEffect(), + getForOwner: () => unusedEffect(), + getManyForOwner: () => unusedEffect(), + list: () => unusedEffect(), + put: () => unusedEffect(), + putMany: () => unusedEffect(), + query: () => unusedEffect(), + count: () => unusedEffect(), + queryKeyset: () => unusedEffect(), + aggregate: { + count: () => unusedEffect(), + groupCount: () => unusedEffect(), + timeBuckets: () => unusedEffect(), + stats: () => unusedEffect(), + }, + remove: () => unusedEffect(), + removeMany: () => unusedEffect(), + ...overrides, +}); + +const makeAiSearch = (): AiSearchInstance => ({ + items: { + upload: async (name) => ({ id: `item:${name}`, key: name }), + list: async () => ({ result: [], result_info: { total_count: 0, page: 1, per_page: 50 } }), + delete: async () => {}, + }, + search: async () => ({ + chunks: [ + { + id: "chunk-1", + score: 0.7, + text: "create a repository", + item: { + key: "github.repos.create.md", + metadata: { + path: "github.default.main.repos.create", + name: "repos.create", + description: "Create a repository", + integration: "github", + }, + }, + }, + { + id: "chunk-2", + score: 0.9, + text: "github repository creation", + item: { + key: "github.repos.create.md", + metadata: { + path: "github.default.main.repos.create", + name: "repos.create", + description: "Create a repository", + integration: "github", + }, + }, + }, + { + id: "chunk-3", + score: 0.8, + text: "send a message", + item: { + key: "slack.messages.send.md", + metadata: { + path: "slack.default.main.messages.send", + name: "messages.send", + description: "Send a message", + integration: "slack", + }, + }, + }, + ], + }), +}); + +describe("makeAiSearchToolDiscoveryProvider", () => { + it.effect("collapses multiple AI Search chunks for the same tool to the best score", () => + Effect.gen(function* () { + const provider = makeAiSearchToolDiscoveryProvider({ + aiSearch: makeAiSearch(), + items: undefined, + }); + + const page = yield* provider!.searchTools({ + executor: undefined as never, + query: "create repo", + limit: 10, + offset: 0, + }); + + expect(page.items.map((item) => item.path)).toEqual([ + "github.default.main.repos.create", + "slack.default.main.messages.send", + ]); + expect(page.items[0]?.score).toBe(0.9); + expect(page.items[0]?.description).toBe("Create a repository"); + expect(page.total).toBe(2); + }), + ); + + it.effect("filters by explicit tool path prefix without defaulting to the tenant namespace", () => + Effect.gen(function* () { + const provider = makeAiSearchToolDiscoveryProvider({ + aiSearch: makeAiSearch(), + items: undefined, + }); + + const unfiltered = yield* provider!.searchTools({ + executor: undefined as never, + query: "tool", + limit: 10, + offset: 0, + }); + const filtered = yield* provider!.searchTools({ + executor: undefined as never, + query: "tool", + namespace: "github", + limit: 10, + offset: 0, + }); + + expect(unfiltered.items).toHaveLength(2); + expect(filtered.items.map((item) => item.path)).toEqual(["github.default.main.repos.create"]); + }), + ); + + it.effect("ignores AI Search chunks whose item key is not tracked locally", () => + Effect.gen(function* () { + const provider = makeAiSearchToolDiscoveryProvider({ + aiSearch: makeAiSearch(), + items: makeItemsCollection({ list: () => Effect.succeed([githubRow]) }), + }); + + const page = yield* provider!.searchTools({ + executor: undefined as never, + query: "tool", + limit: 10, + offset: 0, + }); + + expect(page.items.map((item) => item.path)).toEqual(["github.default.main.repos.create"]); + expect(page.total).toBe(1); + }), + ); +}); + +describe("reindexAiSearch", () => { + it.effect("removes stale rows even when deleting the remote AI Search item fails", () => + Effect.gen(function* () { + const removed: string[] = []; + const result = yield* reindexAiSearch({ + executor: { + tools: { + manifest: () => Effect.succeed([]), + }, + } as never, + aiSearch: { + ...makeAiSearch(), + items: { + ...makeAiSearch().items, + delete: async () => { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: test double for rejected AI Search delete promise + throw new Error("delete failed"); + }, + }, + }, + items: makeItemsCollection({ + list: () => Effect.succeed([githubRow]), + remove: ({ key }) => + Effect.sync(() => { + removed.push(key); + }), + }), + owner: "org", + namespace: "org", + }); + + expect(result.removed).toBe(1); + expect(removed).toEqual(["github.default.main.repos.create"]); + }), + ); +}); diff --git a/packages/plugins/semantic-search/src/sdk/ai-search.ts b/packages/plugins/semantic-search/src/sdk/ai-search.ts new file mode 100644 index 000000000..9ac29a9f6 --- /dev/null +++ b/packages/plugins/semantic-search/src/sdk/ai-search.ts @@ -0,0 +1,479 @@ +import { + ExecutionToolError, + type Executor, + type PagedResult, + type PluginStorageCollectionFacade, + type ToolDiscoveryProvider, + type ToolDiscoveryResult, +} from "@executor-js/sdk/core"; +import { Effect } from "effect"; + +import { type AiSearchItemRow, aiSearchItems, type AiSearchItemStatus } from "./collections"; +import { + collectToolSearchDocument, + listToolManifests, + toolItemKey, + type ToolSearchDocument, +} from "./documents"; +import { SemanticSearchError } from "./errors"; +import type { + SemanticSearchRefreshResult, + SemanticSearchResultPage, + SemanticSearchStatus, + ToolSearchBackendFactory, +} from "./tool-search-backend"; +import type { ToolSearchIndex } from "./tool-search-index"; + +export interface AiSearchUploadedItem { + readonly id: string; + readonly key: string; +} + +export interface AiSearchListedItem { + readonly id: string; + readonly key: string; + readonly status: string; + readonly metadata?: Readonly>; +} + +export interface AiSearchInstance { + readonly items: { + readonly upload: ( + name: string, + content: string | ArrayBuffer | ReadableStream, + options?: { readonly metadata?: Readonly> }, + ) => Promise; + readonly list: (input?: { + readonly page?: number; + readonly per_page?: number; + readonly status?: string; + readonly sort_by?: string; + readonly search?: string; + readonly source?: string; + }) => Promise<{ + readonly result: readonly AiSearchListedItem[]; + readonly result_info?: { + readonly count?: number; + readonly total_count?: number; + readonly page?: number; + readonly per_page?: number; + }; + }>; + readonly delete: (itemId: string) => Promise; + }; + readonly search: (input: { + readonly messages: readonly [{ readonly role: "user"; readonly content: string }]; + readonly ai_search_options?: { + readonly retrieval?: { + readonly retrieval_type?: "vector" | "keyword" | "hybrid"; + readonly max_num_results?: number; + readonly metadata_only?: boolean; + readonly return_on_failure?: boolean; + }; + readonly reranking?: { readonly enabled?: boolean }; + }; + }) => Promise; +} + +export interface AiSearchChunk { + readonly id: string; + readonly score: number; + readonly text?: string; + readonly item?: { + readonly key?: string; + readonly metadata?: Readonly>; + }; +} + +export interface AiSearchSearchResponse { + readonly search_query?: string; + readonly chunks?: readonly AiSearchChunk[]; +} + +export interface AiSearchToolSearchBackendOptions { + readonly aiSearch: AiSearchInstance | undefined; + readonly namespace?: string; +} + +type ItemsCollection = PluginStorageCollectionFacade; + +export interface AiSearchToolSearchBackendStorage { + readonly aiSearchItems: ItemsCollection; + readonly owner: "org" | "user"; +} + +const DEFAULT_SEARCH_LIMIT = 20; + +const nowIso = (): string => new Date().toISOString(); + +const toStatus = (status: string | undefined): AiSearchItemStatus => + status === "queued" || status === "running" || status === "completed" || status === "error" + ? status + : "queued"; + +const toItemName = (document: ToolSearchDocument): string => `${document.path}.md`; + +const mapStorageError = + (message: string) => + (cause: unknown): SemanticSearchError => + new SemanticSearchError({ message, cause }); + +const mapUploadError = + (document: ToolSearchDocument) => + (cause: unknown): SemanticSearchError => + new SemanticSearchError({ + message: `Failed to upload AI Search item "${document.path}".`, + cause, + }); + +const notConfigured = (): Effect.Effect => + Effect.fail( + new SemanticSearchError({ + message: "Semantic search is not configured (missing AI Search).", + }), + ); + +const unavailableIndex: ToolSearchIndex.Service = { + create: () => notConfigured(), + scan: () => notConfigured(), + chunk: () => notConfigured(), + embed: () => notConfigured(), + commit: () => notConfigured(), + fail: () => notConfigured(), + reconcile: () => notConfigured(), + status: () => notConfigured(), + complete: () => notConfigured(), +}; + +const deleteItem = ( + aiSearch: AiSearchInstance, + itemId: string, +): Effect.Effect => + Effect.tryPromise({ + try: () => aiSearch.items.delete(itemId), + catch: (cause) => + new SemanticSearchError({ message: `Failed to delete AI Search item "${itemId}".`, cause }), + }).pipe(Effect.asVoid); + +const deleteItemBestEffort = ( + aiSearch: AiSearchInstance, + itemId: string, +): Effect.Effect => deleteItem(aiSearch, itemId).pipe(Effect.catch(() => Effect.void)); + +const putIndexedItem = ( + items: ItemsCollection, + owner: "user" | "org", + document: ToolSearchDocument, + uploaded: AiSearchUploadedItem, +): Effect.Effect => + items + .put({ + owner, + key: document.path, + data: { + path: document.path, + key: uploaded.key, + itemId: uploaded.id, + name: document.name, + description: document.description, + integration: document.integration, + connection: document.connection, + plugin: document.plugin, + fingerprint: document.fingerprint, + status: "queued", + updatedAt: nowIso(), + }, + }) + .pipe(Effect.asVoid, Effect.mapError(mapStorageError("Failed to record AI Search item."))); + +export const reindexAiSearch = (input: { + readonly executor: Executor; + readonly aiSearch: AiSearchInstance | undefined; + readonly items: ItemsCollection; + readonly owner: "user" | "org"; + readonly namespace: string; + readonly maxTools?: number; +}): Effect.Effect => { + if (!input.aiSearch) return notConfigured(); + const aiSearch = input.aiSearch; + return Effect.gen(function* () { + const manifests = yield* listToolManifests(input.executor, { maxTools: input.maxTools }); + const livePaths = new Set(manifests.map((manifest) => manifest.path)); + const existingEntries = yield* input.items + .list() + .pipe(Effect.mapError(mapStorageError("Failed to list AI Search item rows."))); + const existingByPath = new Map(existingEntries.map((entry) => [entry.key, entry.data])); + let indexed = 0; + let skipped = 0; + + for (const manifest of manifests) { + const previous = existingByPath.get(manifest.path); + const fingerprint = toolItemKey(manifest); + if (previous?.fingerprint === fingerprint) { + skipped += 1; + continue; + } + const document = yield* collectToolSearchDocument(input.executor, manifest); + const uploaded = yield* Effect.tryPromise({ + try: () => + aiSearch.items.upload(toItemName(document), document.content, { + metadata: document.metadata, + }), + catch: mapUploadError(document), + }); + yield* putIndexedItem(input.items, input.owner, document, uploaded).pipe( + Effect.tapError(() => deleteItemBestEffort(aiSearch, uploaded.id)), + ); + if (previous) { + yield* deleteItemBestEffort(aiSearch, previous.itemId); + } + indexed += 1; + } + + let removed = 0; + for (const entry of existingEntries) { + if (livePaths.has(entry.key)) continue; + yield* input.items + .remove({ owner: input.owner, key: entry.key }) + .pipe(Effect.mapError(mapStorageError("Failed to remove stale AI Search item row."))); + yield* deleteItemBestEffort(aiSearch, entry.data.itemId); + removed += 1; + } + + return { namespace: input.namespace, total: manifests.length, indexed, skipped, removed }; + }); +}; + +const listAiSearchItems = ( + aiSearch: AiSearchInstance, +): Effect.Effect => + Effect.gen(function* () { + const all: AiSearchListedItem[] = []; + let page = 1; + while (true) { + const result = yield* Effect.tryPromise({ + try: () => aiSearch.items.list({ page, per_page: 50 }), + catch: (cause) => + new SemanticSearchError({ message: "Failed to list AI Search items.", cause }), + }); + all.push(...result.result); + const info = result.result_info; + const total = info?.total_count; + const perPage = info?.per_page ?? 50; + if (total !== undefined ? all.length >= total : result.result.length < perPage) break; + page += 1; + } + return all; + }); + +export const statusAiSearch = (input: { + readonly aiSearch: AiSearchInstance; + readonly items: ItemsCollection; + readonly namespace: string; +}): Effect.Effect => + Effect.gen(function* () { + const [rows, aiItems] = yield* Effect.all( + [ + input.items.list().pipe(Effect.mapError(mapStorageError("Failed to list AI Search rows."))), + listAiSearchItems(input.aiSearch), + ] as const, + { concurrency: 2 }, + ); + const aiByKey = new Map(aiItems.map((item) => [item.key, item])); + const counts = { + queued: 0, + running: 0, + completed: 0, + error: 0, + skipped: 0, + outdated: 0, + }; + let lastActivity: string | undefined; + for (const row of rows) { + const remote = aiByKey.get(row.data.key); + const status = remote?.status; + if (status === "skipped") { + counts.skipped += 1; + } else if (status === "outdated") { + counts.outdated += 1; + } else { + counts[toStatus(status)] += 1; + } + if (!lastActivity || row.data.updatedAt > lastActivity) lastActivity = row.data.updatedAt; + } + return { + namespace: input.namespace, + indexed: rows.length, + lexical: null, + ...counts, + ...(lastActivity ? { lastActivity } : {}), + }; + }); + +const matchesNamespace = (path: string, namespace: string | undefined): boolean => + !namespace || path === namespace || path.startsWith(`${namespace}.`); + +const rowToResult = (row: AiSearchItemRow, score: number): ToolDiscoveryResult => ({ + path: row.path, + name: row.name, + description: row.description, + integration: row.integration, + score, +}); + +const chunkToResult = (chunk: AiSearchChunk): ToolDiscoveryResult | null => { + const metadata = chunk.item?.metadata; + const path = metadata?.path; + const name = metadata?.name; + const integration = metadata?.integration; + if (!path || !name || !integration) return null; + return { + path, + name, + description: metadata.description, + integration, + score: chunk.score, + }; +}; + +export const makeAiSearchToolDiscoveryProvider = (deps: { + readonly aiSearch: AiSearchInstance | undefined; + readonly items: ItemsCollection | undefined; +}): ToolDiscoveryProvider | undefined => { + if (!deps.aiSearch) return undefined; + const aiSearch = deps.aiSearch; + return { + searchTools: (input) => + Effect.gen(function* () { + const query = input.query.trim(); + if (!query) { + return { items: [], total: 0, hasMore: false, nextOffset: null }; + } + const limit = Math.min(50, Math.max(1, input.limit + input.offset)); + const response = yield* Effect.tryPromise({ + try: () => + aiSearch.search({ + messages: [{ role: "user", content: query }], + ai_search_options: { + retrieval: { + retrieval_type: "hybrid", + max_num_results: limit, + return_on_failure: true, + }, + reranking: { enabled: true }, + }, + }), + catch: (cause) => + new ExecutionToolError({ message: "AI Search tool search failed.", cause }), + }); + + const hasLocalRows = deps.items !== undefined; + const rowsByKey = + deps.items === undefined + ? undefined + : yield* deps.items.list().pipe( + Effect.map((rows) => new Map(rows.map((row) => [row.data.key, row.data]))), + Effect.mapError( + (cause) => + new ExecutionToolError({ + message: "AI Search tool search failed.", + cause, + }), + ), + ); + const bestByPath = new Map(); + for (const chunk of response.chunks ?? []) { + const row = chunk.item?.key ? rowsByKey?.get(chunk.item.key) : undefined; + const result = row + ? rowToResult(row, chunk.score) + : hasLocalRows + ? null + : chunkToResult(chunk); + if (!result || !matchesNamespace(result.path, input.namespace)) continue; + const previous = bestByPath.get(result.path); + if (!previous || result.score > previous.score) bestByPath.set(result.path, result); + } + const ordered = [...bestByPath.values()].sort( + (left, right) => right.score - left.score || left.path.localeCompare(right.path), + ); + const pageItems = ordered.slice(input.offset, input.offset + input.limit); + return { + items: pageItems, + total: ordered.length, + hasMore: input.offset + pageItems.length < ordered.length, + nextOffset: + input.offset + pageItems.length < ordered.length + ? input.offset + pageItems.length + : null, + } satisfies PagedResult; + }), + }; +}; + +export const makeAiSearchToolSearchBackend = ( + options: AiSearchToolSearchBackendOptions, +): ToolSearchBackendFactory => { + const namespace = options.namespace ?? "default"; + return { + namespace, + pluginStorage: { aiSearchItems }, + storage: (deps): AiSearchToolSearchBackendStorage => ({ + aiSearchItems: deps.pluginStorage.collection(aiSearchItems), + owner: "org" as const, + }), + build: ({ storage }) => { + const provider = makeAiSearchToolDiscoveryProvider({ + aiSearch: options.aiSearch, + items: storage.aiSearchItems, + }); + return { + namespace, + provider, + index: () => unavailableIndex, + reindex: (executor) => + reindexAiSearch({ + executor, + aiSearch: options.aiSearch, + items: storage.aiSearchItems, + owner: storage.owner, + namespace, + }), + sweep: () => + Effect.succeed({ + namespace, + removed: 0, + }), + search: (executor, input): Effect.Effect => + provider + ? provider + .searchTools({ + executor, + query: input.query, + namespace: input.namespace, + limit: input.limit ?? DEFAULT_SEARCH_LIMIT, + offset: 0, + }) + .pipe( + Effect.map((page) => ({ + namespace, + query: input.query, + items: page.items, + })), + Effect.mapError( + (cause) => + new SemanticSearchError({ message: "AI Search query failed.", cause }), + ), + ) + : notConfigured(), + status: () => + options.aiSearch + ? statusAiSearch({ + aiSearch: options.aiSearch, + items: storage.aiSearchItems, + namespace, + }) + : notConfigured(), + }; + }, + }; +}; diff --git a/packages/plugins/semantic-search/src/sdk/collections.ts b/packages/plugins/semantic-search/src/sdk/collections.ts index 722fe09bb..66457df39 100644 --- a/packages/plugins/semantic-search/src/sdk/collections.ts +++ b/packages/plugins/semantic-search/src/sdk/collections.ts @@ -2,6 +2,29 @@ import { Schema } from "effect"; import { definePluginStorageCollection } from "@executor-js/sdk/core"; +export const AiSearchItemStatus = Schema.Literals(["queued", "running", "completed", "error"]); +export type AiSearchItemStatus = typeof AiSearchItemStatus.Type; + +export const AiSearchItemRow = Schema.Struct({ + path: Schema.String, + key: Schema.String, + itemId: Schema.String, + name: Schema.String, + description: Schema.String, + integration: Schema.String, + connection: Schema.optional(Schema.String), + plugin: Schema.optional(Schema.String), + fingerprint: Schema.String, + status: AiSearchItemStatus, + updatedAt: Schema.String, + error: Schema.optional(Schema.String), +}); +export type AiSearchItemRow = typeof AiSearchItemRow.Type; + +export const aiSearchItems = definePluginStorageCollection("aiSearchItems", AiSearchItemRow, { + indexes: ["path", "key", "integration", "status", "updatedAt"], +}); + // --------------------------------------------------------------------------- // Vectorize-search plugin storage collections. // diff --git a/packages/plugins/semantic-search/src/sdk/documents.ts b/packages/plugins/semantic-search/src/sdk/documents.ts index 20ea2beff..e8f801273 100644 --- a/packages/plugins/semantic-search/src/sdk/documents.ts +++ b/packages/plugins/semantic-search/src/sdk/documents.ts @@ -6,6 +6,9 @@ import { SemanticSearchError } from "./errors"; import { cyrb53 } from "./fingerprint"; const ADDRESS_PREFIX = "tools."; +const MAX_AI_SEARCH_FILE_BYTES = 3_500_000; + +const textEncoder = new TextEncoder(); export interface IndexableToolDescriptor { readonly address: Tool["address"] | string; @@ -158,6 +161,41 @@ export const listToolManifests = ( Effect.map((manifests) => selectToolManifests(manifests, options)), ); +const truncateToAiSearchLimit = (document: string): string => { + if (textEncoder.encode(document).byteLength <= MAX_AI_SEARCH_FILE_BYTES) return document; + let low = 0; + let high = document.length; + while (low < high) { + const mid = Math.floor((low + high + 1) / 2); + if (textEncoder.encode(document.slice(0, mid)).byteLength <= MAX_AI_SEARCH_FILE_BYTES) { + low = mid; + } else { + high = mid - 1; + } + } + return document.slice(0, low); +}; + +export const toolItemKey = (manifest: ToolSchemaManifest): string => + [ + manifest.path, + manifest.fingerprintVersion, + manifest.indexFingerprint, + manifest.sourceRevision ?? "", + ].join(":"); + +export interface ToolSearchDocument { + readonly path: string; + readonly name: string; + readonly description: string; + readonly integration: string; + readonly connection?: string; + readonly plugin?: string; + readonly fingerprint: string; + readonly content: string; + readonly metadata: Readonly>; +} + const isPlainRecord = (value: unknown): value is Readonly> => typeof value === "object" && value !== null && !Array.isArray(value); @@ -208,6 +246,13 @@ const schemaFacetText = (label: string, schema: unknown): string | undefined => return `${label} ${Array.from(terms).join(" ")}`; }; +const schemaSection = (title: string, schema: unknown): string | undefined => { + const terms = new Set(); + collectSchemaTerms(schema, terms); + if (terms.size === 0) return undefined; + return `## ${title}\n\n${Array.from(terms).join("\n")}`; +}; + const schemaDefinitionsFacetText = ( definitions: Readonly> | undefined, ): string | undefined => { @@ -256,6 +301,55 @@ export const collectDocForTool = ( ); }; +export const collectToolSearchDocument = ( + executor: Executor, + manifest: ToolSchemaManifest, +): Effect.Effect => { + const path = manifest.path; + const name = manifest.name; + const description = stripHtml(manifest.description ?? ""); + const fingerprint = toolItemKey(manifest); + return executor.tools.schema(`${ADDRESS_PREFIX}${path}` as Tool["address"]).pipe( + Effect.mapError( + (cause) => + new SemanticSearchError({ + message: `Failed to collect schema for AI Search item "${path}".`, + cause, + }), + ), + Effect.map((view) => { + const sections = [ + `# ${path}`, + `Name: ${name}`, + `Integration: ${manifest.integration}`, + manifest.connection ? `Connection: ${manifest.connection}` : undefined, + manifest.pluginId ? `Plugin: ${manifest.pluginId}` : undefined, + description ? `Description: ${description}` : undefined, + view ? schemaSection("Input schema", view.inputSchema) : undefined, + view ? schemaSection("Output schema", view.outputSchema) : undefined, + view ? schemaSection("Definitions", view.schemaDefinitions) : undefined, + ].filter((section): section is string => section !== undefined && section.length > 0); + return { + path, + name, + description, + integration: manifest.integration, + ...(manifest.connection ? { connection: manifest.connection } : {}), + ...(manifest.pluginId ? { plugin: manifest.pluginId } : {}), + fingerprint, + content: truncateToAiSearchLimit(sections.join("\n\n")), + metadata: { + path, + name, + description: description.slice(0, 1_000), + integration: manifest.integration, + connection: manifest.connection ?? "", + }, + }; + }), + ); +}; + /** Collect `ToolDocumentInput` for a specific set of tool descriptors. * * This per-tool schema → TypeScript codegen is the CPU-heavy part of indexing, diff --git a/packages/plugins/semantic-search/src/sdk/index.ts b/packages/plugins/semantic-search/src/sdk/index.ts index 2ef1b26f3..9b0c50a7f 100644 --- a/packages/plugins/semantic-search/src/sdk/index.ts +++ b/packages/plugins/semantic-search/src/sdk/index.ts @@ -14,6 +14,19 @@ export { type VectorToolSearchBackendStorage, type VectorToolSearchBackendOptions, } from "./tool-search-backend"; +export { + makeAiSearchToolDiscoveryProvider, + makeAiSearchToolSearchBackend, + reindexAiSearch, + statusAiSearch, + type AiSearchChunk, + type AiSearchInstance, + type AiSearchListedItem, + type AiSearchSearchResponse, + type AiSearchToolSearchBackendOptions, + type AiSearchToolSearchBackendStorage, + type AiSearchUploadedItem, +} from "./ai-search"; export { makeVectorToolDiscoveryProvider } from "./provider"; export { makeGeminiEmbedder, @@ -59,10 +72,15 @@ export { fingerprintTool, type FingerprintInput } from "./fingerprint"; // Collections (plugin storage) export { + aiSearchItems, + AiSearchItemRow, + AiSearchItemStatus, toolFingerprints, indexRuns, indexJobs, indexChunks, + type AiSearchItemRow as AiSearchItemRowType, + type AiSearchItemStatus as AiSearchItemStatusType, FingerprintRow, IndexRun, IndexJob, diff --git a/packages/plugins/semantic-search/src/sdk/tool-search-backend.ts b/packages/plugins/semantic-search/src/sdk/tool-search-backend.ts index 2acc55273..00fc6be78 100644 --- a/packages/plugins/semantic-search/src/sdk/tool-search-backend.ts +++ b/packages/plugins/semantic-search/src/sdk/tool-search-backend.ts @@ -34,6 +34,13 @@ export interface SemanticSearchStatus { readonly namespace: string; readonly indexed: number; readonly lexical: number | null; + readonly queued?: number; + readonly running?: number; + readonly completed?: number; + readonly error?: number; + readonly skipped?: number; + readonly outdated?: number; + readonly lastActivity?: string; } export interface SemanticSearchRefreshResult { From 717c784db431c18f8bd91a6653931c54c3a2bc09 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Fri, 26 Jun 2026 19:11:59 +0530 Subject: [PATCH 2/4] fix(semantic-search): truncate ai search documents by bytes (greptile) --- .../semantic-search/src/sdk/documents.ts | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/plugins/semantic-search/src/sdk/documents.ts b/packages/plugins/semantic-search/src/sdk/documents.ts index e8f801273..91f201465 100644 --- a/packages/plugins/semantic-search/src/sdk/documents.ts +++ b/packages/plugins/semantic-search/src/sdk/documents.ts @@ -9,6 +9,7 @@ const ADDRESS_PREFIX = "tools."; const MAX_AI_SEARCH_FILE_BYTES = 3_500_000; const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); export interface IndexableToolDescriptor { readonly address: Tool["address"] | string; @@ -162,18 +163,12 @@ export const listToolManifests = ( ); const truncateToAiSearchLimit = (document: string): string => { - if (textEncoder.encode(document).byteLength <= MAX_AI_SEARCH_FILE_BYTES) return document; - let low = 0; - let high = document.length; - while (low < high) { - const mid = Math.floor((low + high + 1) / 2); - if (textEncoder.encode(document.slice(0, mid)).byteLength <= MAX_AI_SEARCH_FILE_BYTES) { - low = mid; - } else { - high = mid - 1; - } - } - return document.slice(0, low); + const bytes = textEncoder.encode(document); + if (bytes.byteLength <= MAX_AI_SEARCH_FILE_BYTES) return document; + + let end = MAX_AI_SEARCH_FILE_BYTES; + while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1; + return textDecoder.decode(bytes.subarray(0, end)); }; export const toolItemKey = (manifest: ToolSchemaManifest): string => From c41a178b7c9ff5c617e18a74c2ba290b52a35600 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Fri, 26 Jun 2026 20:48:49 +0530 Subject: [PATCH 3/4] refactor(cloudflare): require ai search backend --- apps/host-cloudflare/src/app.ts | 2 -- apps/host-cloudflare/src/config.ts | 17 ++--------------- apps/host-cloudflare/src/execution.ts | 9 +-------- apps/host-cloudflare/src/plugins.ts | 18 ++---------------- 4 files changed, 5 insertions(+), 41 deletions(-) diff --git a/apps/host-cloudflare/src/app.ts b/apps/host-cloudflare/src/app.ts index 08efc41a1..c27a0ce5d 100644 --- a/apps/host-cloudflare/src/app.ts +++ b/apps/host-cloudflare/src/app.ts @@ -41,8 +41,6 @@ export const makeCloudflareApp = async (env: CloudflareEnv) => { config.secretKey, env.ANALYTICS, config.aiSearch, - env.VECTORIZE, - config.geminiApiKey, config.organizationId, ); diff --git a/apps/host-cloudflare/src/config.ts b/apps/host-cloudflare/src/config.ts index 6dc9e497b..1976e3cc6 100644 --- a/apps/host-cloudflare/src/config.ts +++ b/apps/host-cloudflare/src/config.ts @@ -5,7 +5,7 @@ import type { R2Bucket, } from "@cloudflare/workers-types"; import type { AnalyticsEngineDataset } from "@executor-js/plugin-execution-metrics/cloudflare"; -import type { AiSearchInstance, VectorizeIndex } from "@executor-js/plugin-semantic-search"; +import type { AiSearchInstance } from "@executor-js/plugin-semantic-search"; import { isValidOrgSlug } from "@executor-js/api"; import { missingPublicOriginWarning, resolvePublicOrigin } from "@executor-js/sdk/public-origin"; @@ -34,17 +34,8 @@ export interface CloudflareEnv { * bound (uncomment `analytics_engine_datasets` in wrangler.jsonc), each * finished execution/tool call writes a data point; absent, metrics are off. */ readonly ANALYTICS?: AnalyticsEngineDataset; - /** AI Search instance binding - preferred semantic `tools.search` backend. */ + /** AI Search instance binding for semantic `tools.search`. */ readonly AI_SEARCH?: AiSearchInstance; - /** Vectorize index binding — opt-in semantic `tools.search`. When bound (add a - * `vectorize` binding in wrangler.jsonc) the semantic-search plugin embeds - * the tool catalog and answers `tools.search` from it; absent, the engine - * keeps its built-in lexical search. */ - readonly VECTORIZE?: VectorizeIndex; - /** Gemini API key (a `wrangler secret`) powering the embeddings for the - * Vectorize search. Absent → semantic search stays inert even if the index - * is bound. */ - readonly GEMINI_API_KEY?: string; /** MCP session Durable Object namespace — one addressable isolate per MCP * session (the DO id IS the session id), so a session survives across the * Worker's stateless isolates. */ @@ -90,9 +81,6 @@ export interface CloudflareConfig { readonly secretKey: string; /** AI Search instance binding for semantic `tools.search`. */ readonly aiSearch?: AiSearchInstance; - /** Gemini API key for the Vectorize search embeddings (a `wrangler secret`). - * Unset → vectorize search is inert. */ - readonly geminiApiKey?: string; readonly allowLocalNetwork: boolean; /** Explicit web base URL (`VITE_PUBLIC_SITE_URL`). Unset on a Worker with no * static URL — the per-request origin is used instead (see RequestWebOrigin). */ @@ -151,7 +139,6 @@ export const loadConfig = (env: CloudflareEnv): CloudflareConfig => { organizationSlug: resolveOrgSlug(env.SELF_HOSTED_ORG_SLUG), secretKey, aiSearch: env.AI_SEARCH, - geminiApiKey: env.GEMINI_API_KEY?.trim() || undefined, allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true", // Pinned origin via the shared resolver. A Worker receives no PaaS platform // vars (env: {} — there is nothing to detect), so only the explicit diff --git a/apps/host-cloudflare/src/execution.ts b/apps/host-cloudflare/src/execution.ts index 940c4b4de..a4f0f058d 100644 --- a/apps/host-cloudflare/src/execution.ts +++ b/apps/host-cloudflare/src/execution.ts @@ -39,14 +39,7 @@ export const makeCloudflarePluginsProvider = ( ): Layer.Layer => Layer.succeed(PluginsProvider)({ plugins: () => - makeCloudflarePlugins( - config.secretKey, - undefined, - config.aiSearch, - undefined, - undefined, - config.organizationId, - ), + makeCloudflarePlugins(config.secretKey, undefined, config.aiSearch, config.organizationId), }); export const makeCloudflareHostConfig = (config: CloudflareConfig): Layer.Layer => diff --git a/apps/host-cloudflare/src/plugins.ts b/apps/host-cloudflare/src/plugins.ts index e2ca7b72d..8037491a9 100644 --- a/apps/host-cloudflare/src/plugins.ts +++ b/apps/host-cloudflare/src/plugins.ts @@ -14,11 +14,7 @@ import { serviceTokensPlugin } from "@executor-js/plugin-service-tokens/server"; import { semanticSearchHttpPlugin } from "@executor-js/plugin-semantic-search/api"; import { makeAiSearchToolSearchBackend, - makeVectorizeStore, - ToolSearchBackend, - withCloudflareLimits, type AiSearchInstance, - type VectorizeIndex, } from "@executor-js/plugin-semantic-search"; // --------------------------------------------------------------------------- @@ -41,31 +37,21 @@ import { // // Semantic search follows the same opt-in-by-binding shape: the plugin is // always in the tuple (its reindex route keeps the API shape stable), but it is -// inert until a search backend binding is present. Prefer Cloudflare AI Search -// when bound; otherwise fall back to the Vectorize + Gemini backend. +// inert until the Cloudflare AI Search binding is present. // --------------------------------------------------------------------------- export const makeCloudflarePlugins = ( secretKey: string, analytics?: AnalyticsEngineDataset, aiSearch?: AiSearchInstance, - vectorize?: VectorizeIndex, - geminiApiKey?: string, searchNamespace?: string, ) => { - const store = vectorize ? withCloudflareLimits(makeVectorizeStore(vectorize)) : undefined; const semanticSearchBackend = aiSearch ? makeAiSearchToolSearchBackend({ aiSearch, namespace: searchNamespace, }) - : store && geminiApiKey - ? ToolSearchBackend.vector({ - store, - geminiApiKey, - namespace: searchNamespace, - }) - : undefined; + : undefined; return [ openApiHttpPlugin(), googleHttpPlugin(), From 0fe5412b3d8df193846ed9d4453a7e8b0e1cde56 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Fri, 26 Jun 2026 21:23:44 +0530 Subject: [PATCH 4/4] fix(semantic-search): tolerate ai search schema failures (greptile) --- .../semantic-search/src/sdk/ai-search.test.ts | 52 +++++++++++++++++++ .../semantic-search/src/sdk/documents.ts | 8 +-- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/packages/plugins/semantic-search/src/sdk/ai-search.test.ts b/packages/plugins/semantic-search/src/sdk/ai-search.test.ts index e61b97351..23f6f82e9 100644 --- a/packages/plugins/semantic-search/src/sdk/ai-search.test.ts +++ b/packages/plugins/semantic-search/src/sdk/ai-search.test.ts @@ -185,6 +185,58 @@ describe("makeAiSearchToolDiscoveryProvider", () => { }); describe("reindexAiSearch", () => { + it.effect("indexes an identity document when schema collection fails", () => + Effect.gen(function* () { + let uploadedContent = ""; + const stored: AiSearchItemRow[] = []; + + const result = yield* reindexAiSearch({ + executor: { + tools: { + manifest: () => + Effect.succeed([ + { + path: "github.default.main.repos.create", + name: "repos.create", + description: "Create a repository", + integration: "github", + fingerprintVersion: "v1", + indexFingerprint: "fingerprint", + }, + ]), + schema: () => Effect.fail("schema unavailable"), + }, + } as never, + aiSearch: { + ...makeAiSearch(), + items: { + ...makeAiSearch().items, + upload: async (name, content) => { + uploadedContent = String(content); + return { id: `item:${name}`, key: name }; + }, + }, + }, + items: makeItemsCollection({ + list: () => Effect.succeed([]), + put: ({ data }) => + Effect.sync(() => { + stored.push(data); + return { ...githubRow, data }; + }), + }), + owner: "org", + namespace: "org", + }); + + expect(result).toMatchObject({ indexed: 1, skipped: 0, removed: 0 }); + expect(uploadedContent).toContain("# github.default.main.repos.create"); + expect(uploadedContent).toContain("Description: Create a repository"); + expect(uploadedContent).not.toContain("Input schema"); + expect(stored[0]?.fingerprint).toBe("github.default.main.repos.create:v1:fingerprint:"); + }), + ); + it.effect("removes stale rows even when deleting the remote AI Search item fails", () => Effect.gen(function* () { const removed: string[] = []; diff --git a/packages/plugins/semantic-search/src/sdk/documents.ts b/packages/plugins/semantic-search/src/sdk/documents.ts index 91f201465..c0c149dac 100644 --- a/packages/plugins/semantic-search/src/sdk/documents.ts +++ b/packages/plugins/semantic-search/src/sdk/documents.ts @@ -305,13 +305,7 @@ export const collectToolSearchDocument = ( const description = stripHtml(manifest.description ?? ""); const fingerprint = toolItemKey(manifest); return executor.tools.schema(`${ADDRESS_PREFIX}${path}` as Tool["address"]).pipe( - Effect.mapError( - (cause) => - new SemanticSearchError({ - message: `Failed to collect schema for AI Search item "${path}".`, - cause, - }), - ), + Effect.catch(() => Effect.succeed(null)), Effect.map((view) => { const sections = [ `# ${path}`,