diff --git a/.changeset/tools-search-performance.md b/.changeset/tools-search-performance.md new file mode 100644 index 000000000..ee9724717 --- /dev/null +++ b/.changeset/tools-search-performance.md @@ -0,0 +1,5 @@ +--- +"@executor-js/execution": patch +--- + +Optimize tools.search performance by precomputing query tokenization, using fast single-pass string normalization with Set lookups, scoping exact namespace enumerations to target integrations, and adding support for multi-namespace arrays and comma-separated slug lists. diff --git a/packages/core/execution/src/engine.ts b/packages/core/execution/src/engine.ts index 8bb9bda07..8b4f07f9b 100644 --- a/packages/core/execution/src/engine.ts +++ b/packages/core/execution/src/engine.ts @@ -340,10 +340,15 @@ const makeFullInvoker = ( ); } - if (args.namespace !== undefined && typeof args.namespace !== "string") { + if ( + args.namespace !== undefined && + typeof args.namespace !== "string" && + (!Array.isArray(args.namespace) || + !args.namespace.every((item) => typeof item === "string")) + ) { return Effect.fail( new ExecutionToolError({ - message: "tools.search namespace must be a string when provided", + message: "tools.search namespace must be a string or array of strings when provided", }), ); } @@ -363,7 +368,7 @@ const makeFullInvoker = ( executor, query: args.query ?? "", limit, - namespace: args.namespace, + namespace: args.namespace as string | readonly string[] | undefined, offset, }) .pipe( diff --git a/packages/core/execution/src/tool-invoker.test.ts b/packages/core/execution/src/tool-invoker.test.ts index 747bd2310..dc8eb61cd 100644 --- a/packages/core/execution/src/tool-invoker.test.ts +++ b/packages/core/execution/src/tool-invoker.test.ts @@ -674,12 +674,66 @@ describe("tool discovery", () => { }), ); + it.effect( + "can narrow discovery to multiple namespaces using an array or comma-separated list", + () => + Effect.gen(function* () { + const executor = yield* makeSearchExecutor(); + + const multiArray = yield* searchTools(executor, "list", 5, { + namespace: ["github", "crm"], + }); + const paths = multiArray.items.map((match) => match.path); + expect(paths).toContain("github.org.main.listRepositoryIssues"); + expect(paths).toContain("crm.org.main.listContacts"); + + const commaSeparated = yield* searchTools(executor, "list", 5, { + namespace: "github, crm", + }); + expect(commaSeparated.items.map((match) => match.path)).toEqual(paths); + + const sandboxResult = yield* createExecutionEngine({ executor, codeExecutor }).execute( + 'return await tools.search({ namespace: ["github", "crm"], query: "list", limit: 5 });', + { onElicitation: acceptAll }, + ); + expect(sandboxResult.error).toBeUndefined(); + expect(sandboxResult.result).toEqual( + expect.objectContaining({ + total: 2, + }), + ); + }), + ); + + it.effect("supports batched multi-namespace searches in a single sandbox execution run", () => + Effect.gen(function* () { + const executor = yield* makeSearchExecutor(); + const engine = createExecutionEngine({ executor, codeExecutor }); + + const sandboxResult = yield* engine.execute( + [ + "const [github, crm] = await Promise.all([", + ' tools.search({ namespace: "github", query: "issues", limit: 5 }),', + ' tools.search({ namespace: "crm", query: "contact", limit: 5 }),', + "]);", + "return { githubTotal: github.total, crmTotal: crm.total };", + ].join("\n"), + { onElicitation: acceptAll }, + ); + expect(sandboxResult.error).toBeUndefined(); + expect(sandboxResult.result).toEqual({ + githubTotal: 1, + crmTotal: 2, + }); + }), + ); + it.effect("lets execution hosts provide custom tool discovery", () => Effect.gen(function* () { const executor = yield* makeSearchExecutor(); const calls: Array<{ readonly query: string; - readonly namespace?: string; + readonly namespace?: string | readonly string[]; readonly limit: number; readonly offset: number; }> = []; diff --git a/packages/core/execution/src/tool-invoker.ts b/packages/core/execution/src/tool-invoker.ts index 2df47644e..4a3b80ef5 100644 --- a/packages/core/execution/src/tool-invoker.ts +++ b/packages/core/execution/src/tool-invoker.ts @@ -11,6 +11,7 @@ import type { import { annotateToolResultOutcome, authToolFailure, + IntegrationSlug, isUserActionableError, isToolResult, ToolResult, @@ -415,7 +416,7 @@ export type ExecutorIntegrationListItem = { export type ToolDiscoveryInput = { readonly executor: Executor; readonly query: string; - readonly namespace?: string; + readonly namespace?: string | readonly string[]; readonly limit: number; readonly offset: number; }; @@ -479,6 +480,7 @@ const toSearchableTool = (tool: Tool): SearchableTool => ({ type PreparedField = { readonly raw: string; readonly tokens: readonly string[]; + readonly tokenSet: ReadonlySet; }; const SEARCH_FIELD_WEIGHTS = { @@ -495,16 +497,31 @@ const normalizeSearchText = (value: string): string => .toLowerCase() .trim(); -const tokenizeSearchText = (value: string): string[] => - normalizeSearchText(value) - .split(/[^a-z0-9]+/) - .map((token) => token.trim()) - .filter(Boolean); +const tokenizeNormalized = (normalized: string): readonly string[] => { + if (normalized.length === 0) return []; + return normalized.split(/[^a-z0-9]+/).filter(Boolean); +}; -const prepareField = (value?: string): PreparedField => ({ - raw: normalizeSearchText(value ?? ""), - tokens: tokenizeSearchText(value ?? ""), -}); +const tokenizeSearchText = (value: string): readonly string[] => + tokenizeNormalized(normalizeSearchText(value)); + +const prepareField = (value?: string, maxLen?: number): PreparedField => { + if (!value || value.length === 0) { + return { + raw: "", + tokens: [], + tokenSet: new Set(), + }; + } + const text = maxLen !== undefined && value.length > maxLen ? value.slice(0, maxLen) : value; + const raw = normalizeSearchText(text); + const tokens = tokenizeNormalized(raw); + return { + raw, + tokens, + tokenSet: new Set(tokens), + }; +}; const scorePreparedField = ( query: string, @@ -539,7 +556,7 @@ const scorePreparedField = ( } for (const token of queryTokens) { - if (field.tokens.includes(token)) { + if (field.tokenSet.has(token)) { score += weight * 4; matchedTokens.add(token); continue; @@ -566,11 +583,30 @@ const scorePreparedField = ( }; }; -const matchesNamespace = (tool: SearchableTool, namespace?: string): boolean => { - if (!namespace || normalizeSearchText(namespace).length === 0) { - return true; +const parseNamespaces = (namespace?: string | readonly string[]): readonly string[] | undefined => { + if (namespace === undefined) return undefined; + if (Array.isArray(namespace)) { + const list = namespace + .map((s) => (typeof s === "string" ? s.trim() : "")) + .filter((s) => s.length > 0); + return list.length > 0 ? list : undefined; + } + if (typeof namespace === "string") { + const trimmed = namespace.trim(); + if (trimmed.length === 0) return undefined; + if (trimmed.includes(",")) { + const list = trimmed + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + return list.length > 0 ? list : undefined; + } + return [trimmed]; } + return undefined; +}; +const matchesSingleNamespace = (tool: SearchableTool, namespace: string): boolean => { const namespaceTokens = tokenizeSearchText(namespace); if (namespaceTokens.length === 0) { return true; @@ -585,10 +621,18 @@ const matchesNamespace = (tool: SearchableTool, namespace?: string): boolean => return isPrefixMatch(integrationTokens) || isPrefixMatch(pathTokens); }; -const scoreToolMatch = (tool: SearchableTool, query: string): ToolDiscoveryResult | null => { - const normalizedQuery = normalizeSearchText(query); - const queryTokens = tokenizeSearchText(query); +const matchesNamespace = (tool: SearchableTool, namespaces?: readonly string[]): boolean => { + if (!namespaces || namespaces.length === 0) { + return true; + } + return namespaces.some((ns) => matchesSingleNamespace(tool, ns)); +}; +const scoreToolMatch = ( + tool: SearchableTool, + normalizedQuery: string, + queryTokens: readonly string[], +): ToolDiscoveryResult | null => { if (normalizedQuery.length === 0 || queryTokens.length === 0) { return null; } @@ -596,7 +640,7 @@ const scoreToolMatch = (tool: SearchableTool, query: string): ToolDiscoveryResul const path = prepareField(tool.path); const integration = prepareField(tool.integration); const name = prepareField(tool.name); - const description = prepareField(tool.description); + const description = prepareField(tool.description, 2048); const fieldScores = [ scorePreparedField(normalizedQuery, queryTokens, path, SEARCH_FIELD_WEIGHTS.path), @@ -638,10 +682,7 @@ const scoreToolMatch = (tool: SearchableTool, query: string): ToolDiscoveryResul score += 8; } - if ( - normalizeSearchText(tool.path) === normalizedQuery || - normalizeSearchText(tool.name) === normalizedQuery - ) { + if (path.raw === normalizedQuery || name.raw === normalizedQuery) { score += 20; } @@ -659,19 +700,23 @@ export const searchTools = Effect.fn("executor.tools.search")(function* ( executor: Executor, query: string, limit = 12, - options?: { readonly namespace?: string; readonly offset?: number }, + options?: { readonly namespace?: string | readonly string[]; readonly offset?: number }, ) { const offset = options?.offset ?? 0; + const namespaces = parseNamespaces(options?.namespace); + const namespaceAttr = namespaces && namespaces.length > 0 ? namespaces.join(",") : undefined; + yield* Effect.annotateCurrentSpan({ "executor.search.query_length": query.length, "executor.search.limit": limit, "executor.search.offset": offset, - ...(options?.namespace ? { "executor.search.namespace": options.namespace } : {}), + ...(namespaceAttr ? { "executor.search.namespace": namespaceAttr } : {}), }); - const emptyQuery = normalizeSearchText(query).length === 0; - const hasNamespace = - options?.namespace !== undefined && normalizeSearchText(options.namespace).length > 0; + const normalizedQuery = normalizeSearchText(query); + const queryTokens = tokenizeNormalized(normalizedQuery); + const emptyQuery = normalizedQuery.length === 0 || queryTokens.length === 0; + const hasNamespace = namespaces !== undefined && namespaces.length > 0; // An empty query with no namespace stays empty: it carries neither a // ranking signal nor a scope, and listing the whole workspace "by default" @@ -685,16 +730,34 @@ export const searchTools = Effect.fn("executor.tools.search")(function* ( } satisfies PagedResult; } - const all = yield* executor.tools.list({ includeAnnotations: false }).pipe( - Effect.mapError( - (cause) => - new ExecutionToolError({ - message: "Failed to list tools for search", - cause, - }), - ), - ); - const searchable = all.map(toSearchableTool); + // Optimize database read: + // If exact namespace enumeration (empty query with namespace), query only the requested integration(s). + let allTools: readonly Tool[]; + if (emptyQuery && namespaces && namespaces.length === 1) { + allTools = yield* executor.tools + .list({ integration: IntegrationSlug.make(namespaces[0]), includeAnnotations: false }) + .pipe( + Effect.mapError( + (cause) => + new ExecutionToolError({ + message: "Failed to list tools for search", + cause, + }), + ), + ); + } else { + allTools = yield* executor.tools.list({ includeAnnotations: false }).pipe( + Effect.mapError( + (cause) => + new ExecutionToolError({ + message: "Failed to list tools for search", + cause, + }), + ), + ); + } + + const searchable = allTools.map(toSearchableTool); // An empty query WITH a namespace is enumeration, not search: there is no // ranking signal, so the namespace's whole catalog comes back sorted by @@ -704,9 +767,10 @@ export const searchTools = Effect.fn("executor.tools.search")(function* ( // google_gmail and google_sheets), which would silently break the census // guarantee: `total` here must reconcile against // `executor.integrations.list`'s per-integration toolCount. + const namespaceSet = hasNamespace ? new Set(namespaces) : null; const ranked: readonly ToolDiscoveryResult[] = emptyQuery ? searchable - .filter((tool) => tool.integration === options?.namespace?.trim()) + .filter((tool) => namespaceSet !== null && namespaceSet.has(tool.integration)) .sort((left, right) => left.path.localeCompare(right.path)) .map((tool) => ({ path: tool.path, @@ -716,15 +780,15 @@ export const searchTools = Effect.fn("executor.tools.search")(function* ( ...(tool.description !== undefined ? { description: tool.description } : {}), })) : searchable - .filter((tool: SearchableTool) => matchesNamespace(tool, options?.namespace)) - .map((tool: SearchableTool) => scoreToolMatch(tool, query)) + .filter((tool: SearchableTool) => matchesNamespace(tool, namespaces)) + .map((tool: SearchableTool) => scoreToolMatch(tool, normalizedQuery, queryTokens)) .filter(Predicate.isNotNull) .sort((left, right) => right.score - left.score || left.path.localeCompare(right.path)); const page = paginate(ranked, offset, limit); yield* Effect.annotateCurrentSpan({ - "executor.search.candidate_count": all.length, + "executor.search.candidate_count": allTools.length, "executor.search.match_count": ranked.length, "executor.search.result_count": page.items.length, "executor.search.has_more": page.hasMore,