Empty-query enumeration filtered tools by exact integration slug after listing everything. Pass it as the read filter instead, so the read refreshes only that integration. Ranked search stays unscoped: its namespace match is token-prefix.
Unified · Split
| @@ -13,6 +13,7 @@ import { · 12 unmodified lines | |||
| 13 | authToolFailure, | 13 | authToolFailure, |
| 14 | isUserActionableError, | 14 | isUserActionableError, |
| 15 | isToolResult, | 15 | isToolResult, |
| 16 | IntegrationSlug, | ||
| 16 | ToolResult, | 17 | ToolResult, |
| 17 | ToolAddress, | 18 | ToolAddress, |
| 18 | parseToolAddress, | 19 | parseToolAddress, |
| @@ -665,13 +666,17 @@ export const searchTools = Effect.fn("executor.tools.search")(function* ( · 646 unmodified lines | |||
| 665 | }); | 666 | }); |
| 666 | 667 | ||
| 667 | const emptyQuery = normalizeSearchText(query).length === 0; | 668 | const emptyQuery = normalizeSearchText(query).length === 0; |
| 668 | const hasNamespace = | 669 | // The exact integration slug an empty-query enumeration reads under; null |
| 669 | options?.namespace !== undefined && normalizeSearchText(options.namespace).length > 0; | 670 | // when the caller named no usable namespace. |
| 671 | const scope = | ||
| 672 | options?.namespace !== undefined && normalizeSearchText(options.namespace).length > 0 | ||
| 673 | ? IntegrationSlug.make(options.namespace.trim()) | ||
| 674 | : null; | ||
| 670 | 675 | ||
| 671 | // An empty query with no namespace stays empty: it carries neither a | 676 | // An empty query with no namespace stays empty: it carries neither a |
| 672 | // ranking signal nor a scope, and listing the whole workspace "by default" | 677 | // ranking signal nor a scope, and listing the whole workspace "by default" |
| 673 | // is exactly the arbitrary dump the ranked search refuses to be. | 678 | // is exactly the arbitrary dump the ranked search refuses to be. |
| 674 | if (emptyQuery && !hasNamespace) { | 679 | if (emptyQuery && scope === null) { |
| 675 | return { | 680 | return { |
| 676 | items: [], | 681 | items: [], |
| 677 | total: 0, | 682 | total: 0, |
| @@ -680,15 +685,26 @@ export const searchTools = Effect.fn("executor.tools.search")(function* ( · 2 unmodified lines | |||
| 680 | } satisfies PagedResult<ToolDiscoveryResult>; | 685 | } satisfies PagedResult<ToolDiscoveryResult>; |
| 681 | } | 686 | } |
| 682 | 687 | ||
| 683 | const all = yield* executor.tools.list({ includeAnnotations: false }).pipe( | 688 | // Enumeration's scope is an exact integration slug (see below), so it is the |
| 684 | Effect.mapError( | 689 | // read's filter, not a post-read predicate: pushing it down lets the list |
| 685 | (cause) => | 690 | // narrow its own catalog refresh to that one integration instead of |
| 686 | new ExecutionToolError({ | 691 | // re-listing every stale connection in the workspace. Ranked search is |
| 687 | message: "Failed to list tools for search", | 692 | // deliberately NOT scoped here — `matchesNamespace` is token-prefix, so a |
| 688 | cause, | 693 | // scoped read would drop the prefix-sibling integrations it means to match. |
| 689 | }), | 694 | const all = yield* executor.tools |
| 690 | ), | 695 | .list({ |
| 691 | ); | 696 | includeAnnotations: false, |
| 697 | ...(emptyQuery && scope !== null ? { integration: scope } : {}), | ||
| 698 | }) | ||
| 699 | .pipe( | ||
| 700 | Effect.mapError( | ||
| 701 | (cause) => | ||
| 702 | new ExecutionToolError({ | ||
| 703 | message: "Failed to list tools for search", | ||
| 704 | cause, | ||
| 705 | }), | ||
| 706 | ), | ||
| 707 | ); | ||
| 692 | const searchable = all.map(toSearchableTool); | 708 | const searchable = all.map(toSearchableTool); |
| 693 | 709 | ||
| 694 | // An empty query WITH a namespace is enumeration, not search: there is no | 710 | // An empty query WITH a namespace is enumeration, not search: there is no |
| @@ -698,10 +714,11 @@ export const searchTools = Effect.fn("executor.tools.search")(function* ( · 3 unmodified lines | |||
| 698 | // sweep in prefix-sibling integrations (namespace "google" matching | 714 | // sweep in prefix-sibling integrations (namespace "google" matching |
| 699 | // google_gmail and google_sheets), which would silently break the census | 715 | // google_gmail and google_sheets), which would silently break the census |
| 700 | // guarantee: `total` here must reconcile against | 716 | // guarantee: `total` here must reconcile against |
| 701 | // `executor.integrations.list`'s per-integration toolCount. | 717 | // `executor.integrations.list`'s per-integration toolCount. That exact match |
| 718 | // is now the `integration` filter on the read above, so `searchable` is | ||
| 719 | // already the namespace's catalog and re-filtering it here would be a no-op. | ||
| 702 | const ranked: readonly ToolDiscoveryResult[] = emptyQuery | 720 | const ranked: readonly ToolDiscoveryResult[] = emptyQuery |
| 703 | ? searchable | 721 | ? searchable |
| 704 | .filter((tool) => tool.integration === options?.namespace?.trim()) | ||
| 705 | .sort((left, right) => left.path.localeCompare(right.path)) | 722 | .sort((left, right) => left.path.localeCompare(right.path)) |
| 706 | .map((tool) => ({ | 723 | .map((tool) => ({ |
| 707 | path: tool.path, | 724 | path: tool.path, |