Wire tool_keywords into the BM25 search arm - #6124
Conversation
find_tool advertised tool_keywords to the model, but the field was decoded off the wire, logged, and dropped. The store's Search took a single query string, so tool_description fed both the semantic and the lexical arm and there was nowhere to put keywords. Models paid tokens for a field that did nothing, and one trusting the schema could write a vague description on the assumption keywords carried the precision. ToolStore.Search now takes a SearchQuery carrying Description and Keywords. Keywords build the FTS5 expression, the description is embedded for semantic matching, and the lexical arm falls back to the description when no usable keyword remains. Collapse the two FTS5 sanitizers into one and drop the phrase-search fallback. A phrase search over a query containing a common word demands exact token adjacency and so reliably matches nothing; dropping the noise word and OR-joining the rest returns results without flooding them. Update the tool_keywords schema description so the model knows semantic matching uses tool_description only and keeps writing a complete one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6124 +/- ##
==========================================
+ Coverage 72.43% 72.48% +0.04%
==========================================
Files 733 733
Lines 75856 75860 +4
==========================================
+ Hits 54945 54985 +40
+ Misses 17016 16967 -49
- Partials 3895 3908 +13 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
jerm-dro
left a comment
There was a problem hiding this comment.
Approve. Correct, minimal, well-tested fix for a real bug.
The bug: FindTool decoded ToolKeywords off the wire, advertised it in the tool's JSON schema, logged it, and then dropped it — Search was only ever called with ToolDescription. So tool_keywords was inert since introduction.
The fix is the right shape: the new types.SearchQuery{Description, Keywords} draws a clean seam so the lexical (FTS5/BM25) and semantic (embedding) arms get distinct inputs — lexical prefers Keywords and falls back to the description's words when keywords are empty or all dropped; semantic always embeds Description.
One thing worth noting in the PR description: this also changes the description-only lexical path, not just adds keywords. Old behavior phrase-searched the whole string whenever a problematic word was present (e.g. "read tool write" → phrase, matches nothing); new behavior drops problematic words and OR-joins the rest ("read" OR "write"). Strict improvement, and the problematicWords comment explains it — but it's a bug fix + a search-quality behavior change in one commit, so the title undersells the second half. Not blocking.
Tests are genuinely good:
TestOptimizer_FindToolPassesKeywordsis the direct regression guard for the dropped-keywords bug.TestSQLiteToolStore_SemanticArmEmbedsDescriptionOnly+ theembeddedTexts()spy pin that the semantic arm embedsDescriptionverbatim and never the keywords — a property result-set assertions can't catch.- The "negative twin" (same description, no keywords → finds nothing) guards against a false positive where the description alone starts matching.
Security posture unchanged and sound: every term is still double-quoted with embedded quotes doubled and ftsExpr is bound via ?, so no FTS5-operator or SQL injection.
Verified locally on the PR branch: go test ./pkg/vmcp/optimizer/... ./pkg/vmcp/schema/... and golangci-lint/go vet on those packages all pass.
Summary
find_tooladvertises atool_keywordsinput to the model, but the field was decoded off the wire, logged once, and dropped.ToolStore.Searchtook a single query string, sotool_descriptionfed both the semantic arm (Embed) and the lexical arm (sanitizeFTS5Query), and there was nowhere to put keywords. The schema's claim that keywords are "combined with tool_description for hybrid search" was false.tool_descriptionon the assumption thattool_keywordscarried the lexical precision. That degrades the only arm running in FTS5-only mode (--optimizerwithout embeddings).ToolStore.Searchnow takes atypes.SearchQuery{Description, Keywords}. Keywords build the FTS5 expression, the description is embedded for semantic matching, and the lexical arm falls back to the description when no usable keyword remains.sanitizeFTS5Termsand dropped the phrase-search fallback. See "Special notes" below.tool_keywordsschema description so the model knows semantic matching usestool_descriptiononly, and keeps writing a complete one.Type of change
Test plan
task test)task lint-fix)Full suite passes. Two failures remain in
pkg/plugins/pluginsvcandpkg/desktop; both are pre-existing onmainand neither package has the changed packages anywhere in its dependency graph, including test deps. Lint is clean apart from a pre-existingG115incmd/thv/app/upgrade.go.Each new test was validated as a real guard by reintroducing the bug and confirming a failure:
TestSQLiteToolStore_Search/keywords_surface_a_tool_the_description_alone_misses,TestSQLiteToolStore_KeywordsOnly/keywords_alone_drive_the_lexical_armTestSQLiteToolStore_SemanticArmEmbedsDescriptionOnly,TestSQLiteToolStore_KeywordsOnly/hybrid_tolerates_an_empty_descriptionThat exercise changed the tests. A first attempt asserted
Contains(results, "run_query")in hybrid mode and survived both mutations, because the semantic arm returns that tool on its own. The lexical assertions now run against FTS5-only stores where the semantic arm cannot mask the result.API Compatibility
v1beta1API, OR theapi-break-allowedlabel is applied and the migration guidance is described above.Changes
internal/types/types.goSearchQuery; widenToolStore.Searchinternal/toolstore/sqlite_store.gosanitizeFTS5Terms; reworkSearchto split the armsoptimizer.goSearch; update the schema taginternal/types/mocks/mock_types.gotask genDoes this introduce a user-facing change?
Yes.
tool_keywordsonfind_toolnow affects results instead of being ignored, and its schema description changed. Separately, atool_descriptioncontaining a common word (name,data,table, ...) now returns results where it previously returned none.Special notes for reviewers
The sanitizer collapse is a second logical change. It could be split out if you'd prefer.
sanitizeFTS5Queryused to fall back to a phrase search whenever the query contained a word fromproblematicWords. A phrase search demands exact token adjacency, so it reliably matched nothing: the new"do something with data"test case returns zero results from the description arm for exactly this reason. Dropping the noise word and OR-joining the rest avoids the result flood the fallback was guarding against without going empty-handed. FourTestSanitizeFTS5Queryexpectations flip as a result, all toward returning results instead of none.Fallback is keyed on the sanitized result, not on
len(Keywords). All-noise keywords like["table","schema"]fall through to the description rather than blanking the lexical arm.fakeEmbeddingClientnow records texts passed toEmbed, behind a mutex sinceSearchcalls it from an errgroup goroutine.EmbedBatch, the write path, is deliberately not recorded soembeddedTexts()reflects queries only.🤖 Generated with Claude Code