# Pluggable Tool Search The path to making `tools.search()` swappable (Algolia, pg-fts, trigram, whatever) without baking any of it into the core. ## Today: in-memory linear scan `searchTools` in `packages/core/execution/src/tool-invoker.ts`: 1. `executor.tools.list()` — `SELECT * FROM tool WHERE scope_id = ?`. 2. In JS, for every tool: normalize + tokenize `id / sourceId / name / description`, score per field with weights, apply coverage + exact- phrase boosts, filter, sort, slice. O(N) per query over the scope. Fine at hundreds of tools, painful past a few thousand. Pulls full rows (including JSONB `input_schema` / `output_schema`) which aren't even used for scoring — wasted I/O. ## Interface One method. No `key` — there's only one search provider per executor, nothing to route. ```ts export interface ToolSearchProvider { readonly search: (q: SearchQuery) => Effect.Effect; } export interface SearchQuery { readonly scopeId: string; readonly query: string; readonly namespace?: string; readonly limit: number; } export interface SearchMatch { readonly id: string; readonly sourceId: string; readonly name: string; readonly description: string; readonly score: number; } ``` Call site: `executor.search.query(q)`. (Rename the inner method from `search` to `query` so we don't get stuck with `executor.search.search(q)` and still have room to grow the surface — `reindex`, `invalidate` — later.) ## Wiring — force the spread ```ts export interface SearchDeps { readonly listTools: (scopeId: string) => Effect.Effect; readonly defaults: ToolSearchProvider; } export interface ExecutorConfig { // ... readonly search?: (deps: SearchDeps) => ToolSearchProvider; } ``` Why `listTools` instead of the whole `Executor`: breaks the chicken-and- egg (executor can't be fully constructed before its search provider is) and keeps the provider contract tight. Why "force the spread" instead of `Partial`: partial overrides silently fall back when a method name gets typoed, and implementing from scratch gets no type hint when a method is missed. Forcing a full return value via `defaults` in deps gives us both the "just write what you override" ergonomics and a complete `ToolSearchProvider` type. Same pattern as plugin `storage:` options and the future `coreStorage:`. ## Override shapes **Replace entirely:** ```ts search: ({ listTools }) => algoliaSearch({ listTools }), ``` **Decorate — mix in logging/timing:** ```ts search: ({ defaults }) => ({ query: (q) => Effect.gen(function* () { const start = Date.now(); const hits = yield* defaults.query(q); console.log(`search ${q.query} scope=${q.scopeId} ms=${Date.now()-start}`); return hits; }), }), ``` **Hybrid — try Algolia, fall back to local:** ```ts search: ({ listTools, defaults }) => ({ query: (q) => algoliaSearch({ listTools }).query(q).pipe( Effect.catchAll(() => defaults.query(q)), ), }), ``` ## Indexing side — separate concern Answering from Algolia/Elasticsearch means keeping an external index in sync with tool writes. That doesn't belong in the `ToolSearchProvider` — it's a storage concern. Handled by wrapping the core store (see `pluggable-storage.md`): override `putTools` / `removeToolsBySource` to delegate to the default and mirror to the external index. The two halves are independent: - Indexing only (manual reindex): override `coreStorage`, leave `search` as default. - Search only (some other populates the index): override `search`, leave `coreStorage` as default. ## Cheap wins before external search Before reaching for Algolia, there's low-hanging fruit on the built-in scorer: 1. **Projection on `tools.list`** — expose a `select` option so search doesn't pull `input_schema` / `output_schema`. Biggest per-query win. 2. **Postgres FTS** — `tsvector` generated column on `(name, description, id, source_id)`, GIN index, use `websearch_to_tsquery`. Native ranking, fuzzy via `pg_trgm`. Would replace the in-memory scorer for the cloud app. 3. **Per-scope LRU** — cache scored results; invalidate on source add/update/remove. The pluggable interface lets us land these as just another provider (`pgFtsSearch({ db })`) without touching the default path.