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:
executor.tools.list()—SELECT * FROM tool WHERE scope_id = ?.- 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.
export interface ToolSearchProvider {
readonly search: (q: SearchQuery) => Effect.Effect<readonly SearchMatch[], Error>;
}
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
export interface SearchDeps {
readonly listTools: (scopeId: string) => Effect.Effect<readonly Tool[], Error>;
readonly defaults: ToolSearchProvider;
}
export interface ExecutorConfig<TPlugins> {
// ...
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<ToolSearchProvider>: 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:
search: ({ listTools }) => algoliaSearch({ listTools }),
Decorate — mix in logging/timing:
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:
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, leavesearchas default. - Search only (some other populates the index): override
search, leavecoreStorageas default.
Cheap wins before external search
Before reaching for Algolia, there's low-hanging fruit on the built-in scorer:
- Projection on
tools.list— expose aselectoption so search doesn't pullinput_schema/output_schema. Biggest per-query win. - Postgres FTS —
tsvectorgenerated column on(name, description, id, source_id), GIN index, usewebsearch_to_tsquery. Native ranking, fuzzy viapg_trgm. Would replace the in-memory scorer for the cloud app. - 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.