From 3cc59d5d66c1fc4d13a8ee4b8ceef0517aa519c7 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:39:12 +0000 Subject: [PATCH 1/7] feat(coderd/x/chatd/chattool): add find_tools limit argument with lower default --- coderd/x/chatd/chattool/findtools.go | 46 ++++++++++++------- .../chatd/chattool/findtools_internal_test.go | 26 +++++++++-- 2 files changed, 53 insertions(+), 19 deletions(-) diff --git a/coderd/x/chatd/chattool/findtools.go b/coderd/x/chatd/chattool/findtools.go index 8338df54787..38dfcf28256 100644 --- a/coderd/x/chatd/chattool/findtools.go +++ b/coderd/x/chatd/chattool/findtools.go @@ -16,9 +16,12 @@ import ( ) const ( - FindToolsName = "find_tools" - findToolsMaxMatches = 20 - findToolsCatalogTokens = 4000 + FindToolsName = "find_tools" + // findToolsDefaultMatches keeps broad queries from flooding results; + // callers raise the per-call limit argument up to findToolsMaxMatches. + findToolsDefaultMatches = 10 + findToolsMaxMatches = 20 + findToolsCatalogTokens = 4000 // findToolsMaxQueries and findToolsMaxQueryTokens bound scoring // work: queries are model output, so one call could otherwise // carry arbitrarily many tokens scored against every entry. @@ -85,6 +88,7 @@ type FindToolsOptions struct { type FindToolsArgs struct { Queries []string `json:"queries,omitempty"` Names []string `json:"names,omitempty"` + Limit int `json:"limit,omitempty" description:"Maximum keyword matches to return and activate (default 10, max 20)."` } type FindToolsMatch struct { @@ -339,14 +343,17 @@ type SearchBudget struct { } // SearchTools includes exact name activations first, then fills the -// remaining match slots with the top-scored keyword matches. The shared -// cap and summary-length descriptions keep the persisted result small -// enough that generic tool-result truncation can never corrupt the -// activation JSON that later steps re-derive activations from. A -// positive budget additionally skips matches whose schema weight would -// push the aggregate past it, admitting later matches that still fit. -// The second result counts matches skipped for budget, so callers can -// tell an exhausted budget from no matches. +// remaining match slots with the top-scored keyword matches. Keyword +// fill stops at the per-call limit argument (default +// findToolsDefaultMatches, clamped to findToolsMaxMatches), while exact +// names are explicit activation requests and bypass the limit up to the +// hard cap. The hard cap and summary-length descriptions keep the +// persisted result small enough that generic tool-result truncation can +// never corrupt the activation JSON that later steps re-derive +// activations from. A positive budget additionally skips matches whose +// schema weight would push the aggregate past it, admitting later +// matches that still fit. The second result counts matches skipped for +// budget, so callers can tell an exhausted budget from no matches. func SearchTools(entries []FindToolCatalogEntry, args FindToolsArgs, budget SearchBudget) (FindToolsResult, int) { byName := make(map[string]FindToolCatalogEntry, len(entries)) for _, entry := range entries { @@ -394,15 +401,22 @@ func SearchTools(entries []FindToolCatalogEntry, args FindToolsArgs, budget Sear } return strings.Compare(a.entry.Name, b.entry.Name) }) + matchLimit := args.Limit + if matchLimit <= 0 { + matchLimit = findToolsDefaultMatches + } + if matchLimit > findToolsMaxMatches { + matchLimit = findToolsMaxMatches + } matches := make([]FindToolsMatch, 0, findToolsMaxMatches) activatedSet := make(map[string]struct{}, findToolsMaxMatches) usedSchemaTokens := 0.0 budgetSkipped := 0 - appendMatch := func(entry FindToolCatalogEntry) { + appendMatch := func(entry FindToolCatalogEntry, limit int) { if _, exists := activatedSet[entry.Name]; exists { return } - if len(matches) >= findToolsMaxMatches { + if len(matches) >= limit { return } overBudget := budget.SchemaTokens > 0 && usedSchemaTokens+entry.SchemaTokens > budget.SchemaTokens @@ -423,11 +437,11 @@ func SearchTools(entries []FindToolCatalogEntry, args FindToolsArgs, budget Sear } for _, name := range nameArgs { if entry, ok := byName[name]; ok { - appendMatch(entry) + appendMatch(entry, findToolsMaxMatches) } } for _, item := range scored { - appendMatch(item.entry) + appendMatch(item.entry, matchLimit) } activated := make([]string, 0, len(activatedSet)) for name := range activatedSet { @@ -616,7 +630,7 @@ func (t findToolsEntryTokens) score(token string) int { } func buildFindToolsDescription(entries []FindToolCatalogEntry, catalogTokenBudget float64) string { - const usage = "Search deferred MCP tools by keyword, activate exact tool names, or scope a query to one server with a \"server: terms\" prefix. Calling a cataloged tool directly by name is allowed and auto-loads its schema, but search first for unfamiliar tools. At most 20 tools are returned and activated per call; call again for more.\n\n" + const usage = "Search deferred MCP tools by keyword, activate exact tool names, or scope a query to one server with a \"server: terms\" prefix. Calling a cataloged tool directly by name is allowed and auto-loads its schema, but search first for unfamiliar tools. At most limit tools are returned and activated per call (default 10, max 20); exact names always activate, up to 20 per call. Narrow the query or raise limit for more.\n\n" budget := float64(findToolsCatalogTokens) if catalogTokenBudget > 0 && catalogTokenBudget < budget { budget = catalogTokenBudget diff --git a/coderd/x/chatd/chattool/findtools_internal_test.go b/coderd/x/chatd/chattool/findtools_internal_test.go index 0a7af3fdc47..6af58a76dc5 100644 --- a/coderd/x/chatd/chattool/findtools_internal_test.go +++ b/coderd/x/chatd/chattool/findtools_internal_test.go @@ -50,8 +50,20 @@ func TestSearchTools(t *testing.T) { many[i] = FindToolCatalogEntry{Name: fmt.Sprintf("server__tool_%02d", i), Description: "common"} } result, _ := SearchTools(many, FindToolsArgs{Queries: []string{"common"}}, SearchBudget{}) - require.Len(t, result.Matches, findToolsMaxMatches) + require.Len(t, result.Matches, findToolsDefaultMatches, + "an omitted limit returns the default match count") require.Equal(t, "server__tool_00", result.Matches[0].Name) + + raised, _ := SearchTools(many, FindToolsArgs{Queries: []string{"common"}, Limit: 15}, SearchBudget{}) + require.Len(t, raised.Matches, 15) + + clamped, _ := SearchTools(many, FindToolsArgs{Queries: []string{"common"}, Limit: 25}, SearchBudget{}) + require.Len(t, clamped.Matches, findToolsMaxMatches, + "a limit above the hard cap clamps to it") + + invalid, _ := SearchTools(many, FindToolsArgs{Queries: []string{"common"}, Limit: -1}, SearchBudget{}) + require.Len(t, invalid.Matches, findToolsDefaultMatches, + "a non-positive limit falls back to the default") }) t.Run("names capped and prioritized over queries", func(t *testing.T) { t.Parallel() @@ -62,13 +74,21 @@ func TestSearchTools(t *testing.T) { names = append(names, many[i].Name) } result, _ := SearchTools(many, FindToolsArgs{Queries: []string{"common"}, Names: []string{"server__tool_24"}}, SearchBudget{}) - require.Len(t, result.Matches, findToolsMaxMatches) + require.Len(t, result.Matches, findToolsDefaultMatches) require.Equal(t, "server__tool_24", result.Matches[0].Name) require.Contains(t, result.Activated, "server__tool_24") capped, _ := SearchTools(many, FindToolsArgs{Names: names}, SearchBudget{}) - require.Len(t, capped.Matches, findToolsMaxMatches) + require.Len(t, capped.Matches, findToolsMaxMatches, + "exact names bypass the default limit up to the hard cap") require.Len(t, capped.Activated, findToolsMaxMatches) + + bypassed, _ := SearchTools(many, FindToolsArgs{Queries: []string{"common"}, Names: names[:12], Limit: 5}, SearchBudget{}) + require.Len(t, bypassed.Matches, 12, + "exact names bypass an explicit lower limit and leave no keyword slots") + for i, name := range names[:12] { + require.Equal(t, name, bypassed.Matches[i].Name) + } }) t.Run("names list is bounded", func(t *testing.T) { t.Parallel() From 5047e29ff614791df2917899097e5e8a9500f217 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:33:43 +0000 Subject: [PATCH 2/7] feat(coderd/x/chatd/chattool): improve find_tools ranking and model guidance --- coderd/x/chatd/chattool/findtools.go | 82 +++++++++++++++++-- .../chatd/chattool/findtools_internal_test.go | 53 ++++++++++++ 2 files changed, 126 insertions(+), 9 deletions(-) diff --git a/coderd/x/chatd/chattool/findtools.go b/coderd/x/chatd/chattool/findtools.go index 38dfcf28256..17d7afe4915 100644 --- a/coderd/x/chatd/chattool/findtools.go +++ b/coderd/x/chatd/chattool/findtools.go @@ -86,8 +86,8 @@ type FindToolsOptions struct { } type FindToolsArgs struct { - Queries []string `json:"queries,omitempty"` - Names []string `json:"names,omitempty"` + Queries []string `json:"queries,omitempty" description:"Task or capability keywords, matched against tool names, descriptions, parameters, and server metadata. Prefer a few specific keywords over sentences."` + Names []string `json:"names,omitempty" description:"Exact cataloged tool names to activate directly."` Limit int `json:"limit,omitempty" description:"Maximum keyword matches to return and activate (default 10, max 20)."` } @@ -343,7 +343,10 @@ type SearchBudget struct { } // SearchTools includes exact name activations first, then fills the -// remaining match slots with the top-scored keyword matches. Keyword +// remaining match slots with keyword matches ranked by distinct query +// terms matched before raw score, so one generic high-weight term +// (such as a server name appearing in every tool name on that server) +// cannot outrank entries that match more of the query. Keyword // fill stops at the per-call limit argument (default // findToolsDefaultMatches, clamped to findToolsMaxMatches), while exact // names are explicit activation requests and bypass the limit up to the @@ -367,13 +370,15 @@ func SearchTools(entries []FindToolCatalogEntry, args FindToolsArgs, budget Sear queries := parseFindToolsQueries(entries, queryArgs) type scoredEntry struct { - entry FindToolCatalogEntry - score int + entry FindToolCatalogEntry + coverage int + score int } scored := make([]scoredEntry, 0, len(entries)) for _, entry := range entries { tokens := tokenizeFindToolsEntry(entry) score := 0 + matched := make(map[string]struct{}) for _, query := range queries { if query.server != "" { if query.exact && entry.Server != query.server { @@ -385,17 +390,27 @@ func SearchTools(entries []FindToolCatalogEntry, args FindToolsArgs, budget Sear } if query.server != "" && len(query.tokens) == 0 { score++ + // A scope-only query's whole content is its server, so + // the scope hit is its one covered term. The ":" prefix + // cannot collide with tokens, which never contain ":". + matched[":"+query.server] = struct{}{} continue } for _, token := range query.tokens { - score += tokens.score(token) + if tokenScore := tokens.score(token); tokenScore > 0 { + score += tokenScore + matched[token] = struct{}{} + } } } if score > 0 { - scored = append(scored, scoredEntry{entry: entry, score: score}) + scored = append(scored, scoredEntry{entry: entry, coverage: len(matched), score: score}) } } slices.SortFunc(scored, func(a, b scoredEntry) int { + if a.coverage != b.coverage { + return b.coverage - a.coverage + } if a.score != b.score { return b.score - a.score } @@ -528,12 +543,61 @@ func parseFindToolsQueries(entries []FindToolCatalogEntry, queries []string) []s } } if !scoped { - parsed = append(parsed, scopedFindToolsQuery{tokens: tokenizeFindToolsQuery(query)}) + parsed = append(parsed, autoScopeFindToolsQuery(servers, query)) } } return parsed } +// autoScopeFindToolsQuery scopes an unprefixed query to a cataloged +// server when exactly one of its whitespace-delimited words names that +// server, so "linear issues" ranks like "linear: issues" instead of the +// server name inflating every tool on that server. An exact-case word +// wins before the case-insensitive fallback, mirroring prefix scopes. +// A query whose words name several servers stays unscoped because the +// intended scope is ambiguous. +func autoScopeFindToolsQuery(servers []string, query string) scopedFindToolsQuery { + words := strings.Fields(query) + scopeIndex := -1 + var scope scopedFindToolsQuery + for i, word := range words { + server, exact, ok := matchFindToolsServerWord(servers, word) + if !ok { + continue + } + if scopeIndex >= 0 { + scopeIndex = -1 + break + } + scopeIndex = i + scope = scopedFindToolsQuery{server: server, exact: exact} + } + if scopeIndex < 0 { + return scopedFindToolsQuery{tokens: tokenizeFindToolsQuery(query)} + } + scope.tokens = tokenizeFindToolsQuery(strings.Join(slices.Delete(words, scopeIndex, scopeIndex+1), " ")) + return scope +} + +// matchFindToolsServerWord resolves one query word to a cataloged +// server name, exact-case first. A folded match reports exact=false so +// scoring spans case-colliding servers, exactly like a folded prefix. +func matchFindToolsServerWord(servers []string, word string) (server string, exact bool, ok bool) { + folded := "" + for _, candidate := range servers { + if word == candidate { + return candidate, true, true + } + if folded == "" && strings.EqualFold(word, candidate) { + folded = candidate + } + } + if folded != "" { + return folded, false, true + } + return "", false, false +} + // cutPrefixFold is a case-insensitive strings.CutPrefix. It compares // rune by rune with the same simple folding as strings.EqualFold, so a // prefix whose folded form differs in UTF-8 byte length (like S and @@ -630,7 +694,7 @@ func (t findToolsEntryTokens) score(token string) int { } func buildFindToolsDescription(entries []FindToolCatalogEntry, catalogTokenBudget float64) string { - const usage = "Search deferred MCP tools by keyword, activate exact tool names, or scope a query to one server with a \"server: terms\" prefix. Calling a cataloged tool directly by name is allowed and auto-loads its schema, but search first for unfamiliar tools. At most limit tools are returned and activated per call (default 10, max 20); exact names always activate, up to 20 per call. Narrow the query or raise limit for more.\n\n" + const usage = "The MCP tools cataloged below exist but are deferred: they stay out of your tool list until activated. Search them by keyword, activate exact tool names, or scope a query to one server with a \"server: terms\" prefix; matches activate and become callable on the next step. Calling a cataloged tool directly by name is allowed and auto-loads its schema, but search first for unfamiliar tools. At most limit tools are returned and activated per call (default 10, max 20); exact names always activate, up to 20 per call. Narrow the query or raise limit for more.\n\n" budget := float64(findToolsCatalogTokens) if catalogTokenBudget > 0 && catalogTokenBudget < budget { budget = catalogTokenBudget diff --git a/coderd/x/chatd/chattool/findtools_internal_test.go b/coderd/x/chatd/chattool/findtools_internal_test.go index 6af58a76dc5..372c5c344c5 100644 --- a/coderd/x/chatd/chattool/findtools_internal_test.go +++ b/coderd/x/chatd/chattool/findtools_internal_test.go @@ -121,6 +121,40 @@ func TestSearchTools(t *testing.T) { "tool description match outranks server metadata match") require.Len(t, result.Matches, 2) }) + t.Run("coverage outranks concentrated score", func(t *testing.T) { + t.Parallel() + coverageEntries := []FindToolCatalogEntry{ + {Name: "tracker__update", Description: "Assign labels and update status"}, + {Name: "labels__tool", Description: "unrelated"}, + } + result, _ := SearchTools(coverageEntries, FindToolsArgs{Queries: []string{"labels status"}}, SearchBudget{}) + require.Len(t, result.Matches, 2) + require.Equal(t, "tracker__update", result.Matches[0].Name, + "an entry matching more distinct query terms outranks a higher single-term score") + }) + t.Run("server-name query words scope automatically", func(t *testing.T) { + t.Parallel() + autoEntries := []FindToolCatalogEntry{ + {Name: "linear__create_issue", Description: "Create an issue", Server: "linear"}, + {Name: "linear__list_teams", Description: "List teams", Server: "linear"}, + {Name: "github__create_issue", Description: "Create an issue imported from linear", Server: "github"}, + } + result, _ := SearchTools(autoEntries, FindToolsArgs{Queries: []string{"linear issue"}}, SearchBudget{}) + require.Equal(t, []string{"linear__create_issue"}, result.Activated, + "a query word naming a server scopes the query to that server") + + result, _ = SearchTools(autoEntries, FindToolsArgs{Queries: []string{"Linear issue"}}, SearchBudget{}) + require.Equal(t, []string{"linear__create_issue"}, result.Activated, + "a case-variant server word still scopes") + + result, _ = SearchTools(autoEntries, FindToolsArgs{Queries: []string{"linear"}}, SearchBudget{}) + require.Equal(t, []string{"linear__create_issue", "linear__list_teams"}, result.Activated, + "a bare server-name query lists that server's tools without cross-server description hits") + + result, _ = SearchTools(autoEntries, FindToolsArgs{Queries: []string{"linear github issue"}}, SearchBudget{}) + require.Len(t, result.Activated, 3, + "words naming two servers leave the query unscoped") + }) t.Run("server prefix scope", func(t *testing.T) { t.Parallel() scopedEntries := []FindToolCatalogEntry{ @@ -160,6 +194,14 @@ func TestSearchTools(t *testing.T) { result, _ = SearchTools(caseEntries, FindToolsArgs{Queries: []string{"GITHUB: status"}}, SearchBudget{}) require.Len(t, result.Activated, 2, "a prefix matching no exact-case name falls back to spanning the case-colliding servers") + + result, _ = SearchTools(caseEntries, FindToolsArgs{Queries: []string{"GitHub status"}}, SearchBudget{}) + require.Equal(t, []string{"GitHub__enterprise_status"}, result.Activated, + "an exact-case server word auto-scopes only to its own server") + + result, _ = SearchTools(caseEntries, FindToolsArgs{Queries: []string{"GITHUB status"}}, SearchBudget{}) + require.Len(t, result.Activated, 2, + "a server word matching no exact-case name spans the case-colliding servers") }) t.Run("folded scopes with different byte lengths", func(t *testing.T) { t.Parallel() @@ -293,6 +335,17 @@ func TestFindTools(t *testing.T) { require.True(t, resp.IsError) } +func TestFindToolsArgDescriptions(t *testing.T) { + t.Parallel() + info := FindTools(FindToolsOptions{}).Info() + for _, name := range []string{"queries", "names", "limit"} { + property, ok := info.Parameters[name].(map[string]any) + require.True(t, ok, "parameter %q must exist in the schema", name) + description, _ := property["description"].(string) + require.NotEmpty(t, description, "parameter %q needs model guidance in its schema description", name) + } +} + func TestFindToolsSerialToolCalls(t *testing.T) { t.Parallel() serial, ok := FindTools(FindToolsOptions{}).(interface{ SerialToolCalls() bool }) From 6a0cf448a5daced2d5244d21ce9a4a7faf1ab7af Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:03:10 +0000 Subject: [PATCH 3/7] fix(coderd/x/chatd/chattool): address find_tools review findings --- coderd/x/chatd/chattool/findtools.go | 11 +++++++++-- coderd/x/chatd/chattool/findtools_internal_test.go | 5 +++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/coderd/x/chatd/chattool/findtools.go b/coderd/x/chatd/chattool/findtools.go index 17d7afe4915..5ae31dfe261 100644 --- a/coderd/x/chatd/chattool/findtools.go +++ b/coderd/x/chatd/chattool/findtools.go @@ -88,7 +88,7 @@ type FindToolsOptions struct { type FindToolsArgs struct { Queries []string `json:"queries,omitempty" description:"Task or capability keywords, matched against tool names, descriptions, parameters, and server metadata. Prefer a few specific keywords over sentences."` Names []string `json:"names,omitempty" description:"Exact cataloged tool names to activate directly."` - Limit int `json:"limit,omitempty" description:"Maximum keyword matches to return and activate (default 10, max 20)."` + Limit int `json:"limit,omitempty" description:"Cap on total tools returned and activated per call (default 10, max 20). Exact names are always included and may exceed it."` } type FindToolsMatch struct { @@ -558,6 +558,13 @@ func parseFindToolsQueries(entries []FindToolCatalogEntry, queries []string) []s // intended scope is ambiguous. func autoScopeFindToolsQuery(servers []string, query string) scopedFindToolsQuery { words := strings.Fields(query) + // Query text is model output, so inspect only as many words as the + // per-query token cap; the nested server scan stays bounded like + // scoring. The unscoped fallback still tokenizes the full query, + // which applies the same cap itself. + if len(words) > findToolsMaxQueryTokens { + words = words[:findToolsMaxQueryTokens] + } scopeIndex := -1 var scope scopedFindToolsQuery for i, word := range words { @@ -694,7 +701,7 @@ func (t findToolsEntryTokens) score(token string) int { } func buildFindToolsDescription(entries []FindToolCatalogEntry, catalogTokenBudget float64) string { - const usage = "The MCP tools cataloged below exist but are deferred: they stay out of your tool list until activated. Search them by keyword, activate exact tool names, or scope a query to one server with a \"server: terms\" prefix; matches activate and become callable on the next step. Calling a cataloged tool directly by name is allowed and auto-loads its schema, but search first for unfamiliar tools. At most limit tools are returned and activated per call (default 10, max 20); exact names always activate, up to 20 per call. Narrow the query or raise limit for more.\n\n" + const usage = "The MCP tools cataloged below are deferred: not in your tool list until activated. Search by keyword, activate exact tool names, or scope with a \"server: terms\" prefix; matches activate and become callable on the next step. Direct calls to cataloged tools also work, but search first for unfamiliar tools. limit caps total results per call (default 10, max 20); exact names bypass it but still spend the shared schema budget. Narrow the query or raise limit for more.\n\n" budget := float64(findToolsCatalogTokens) if catalogTokenBudget > 0 && catalogTokenBudget < budget { budget = catalogTokenBudget diff --git a/coderd/x/chatd/chattool/findtools_internal_test.go b/coderd/x/chatd/chattool/findtools_internal_test.go index 372c5c344c5..23a2bb45ddc 100644 --- a/coderd/x/chatd/chattool/findtools_internal_test.go +++ b/coderd/x/chatd/chattool/findtools_internal_test.go @@ -154,6 +154,11 @@ func TestSearchTools(t *testing.T) { result, _ = SearchTools(autoEntries, FindToolsArgs{Queries: []string{"linear github issue"}}, SearchBudget{}) require.Len(t, result.Activated, 3, "words naming two servers leave the query unscoped") + + overflowScope := strings.Repeat("issue ", findToolsMaxQueryTokens) + "linear" + result, _ = SearchTools(autoEntries, FindToolsArgs{Queries: []string{overflowScope}}, SearchBudget{}) + require.Len(t, result.Activated, 2, + "a server word beyond the word-inspection cap does not scope") }) t.Run("server prefix scope", func(t *testing.T) { t.Parallel() From 27c94dc3816e17e60117ff19a12ca83b2f608ffc Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:25:46 +0000 Subject: [PATCH 4/7] fix(coderd/x/chatd/chattool): merge repeated server words in find_tools auto-scope --- coderd/x/chatd/chattool/findtools.go | 71 +++++++------------ .../chatd/chattool/findtools_internal_test.go | 4 ++ 2 files changed, 31 insertions(+), 44 deletions(-) diff --git a/coderd/x/chatd/chattool/findtools.go b/coderd/x/chatd/chattool/findtools.go index 5ae31dfe261..51cfee9dd47 100644 --- a/coderd/x/chatd/chattool/findtools.go +++ b/coderd/x/chatd/chattool/findtools.go @@ -17,8 +17,7 @@ import ( const ( FindToolsName = "find_tools" - // findToolsDefaultMatches keeps broad queries from flooding results; - // callers raise the per-call limit argument up to findToolsMaxMatches. + // Keep broad query results concise while allowing higher explicit limits. findToolsDefaultMatches = 10 findToolsMaxMatches = 20 findToolsCatalogTokens = 4000 @@ -342,21 +341,11 @@ type SearchBudget struct { AllowFirstOverBudget bool } -// SearchTools includes exact name activations first, then fills the -// remaining match slots with keyword matches ranked by distinct query -// terms matched before raw score, so one generic high-weight term -// (such as a server name appearing in every tool name on that server) -// cannot outrank entries that match more of the query. Keyword -// fill stops at the per-call limit argument (default -// findToolsDefaultMatches, clamped to findToolsMaxMatches), while exact -// names are explicit activation requests and bypass the limit up to the -// hard cap. The hard cap and summary-length descriptions keep the -// persisted result small enough that generic tool-result truncation can -// never corrupt the activation JSON that later steps re-derive -// activations from. A positive budget additionally skips matches whose -// schema weight would push the aggregate past it, admitting later -// matches that still fit. The second result counts matches skipped for -// budget, so callers can tell an exhausted budget from no matches. +// SearchTools prioritizes exact names, then keyword matches ranked by +// distinct query terms matched before raw score. Exact names bypass the +// per-call limit, but a hard cap keeps the persisted result safe from +// generic tool-result truncation; the second return counts +// budget-skipped matches. func SearchTools(entries []FindToolCatalogEntry, args FindToolsArgs, budget SearchBudget) (FindToolsResult, int) { byName := make(map[string]FindToolCatalogEntry, len(entries)) for _, entry := range entries { @@ -390,9 +379,7 @@ func SearchTools(entries []FindToolCatalogEntry, args FindToolsArgs, budget Sear } if query.server != "" && len(query.tokens) == 0 { score++ - // A scope-only query's whole content is its server, so - // the scope hit is its one covered term. The ":" prefix - // cannot collide with tokens, which never contain ":". + // A scope-only hit counts as one covered term; ":" cannot occur in tokens. matched[":"+query.server] = struct{}{} continue } @@ -549,46 +536,42 @@ func parseFindToolsQueries(entries []FindToolCatalogEntry, queries []string) []s return parsed } -// autoScopeFindToolsQuery scopes an unprefixed query to a cataloged -// server when exactly one of its whitespace-delimited words names that -// server, so "linear issues" ranks like "linear: issues" instead of the -// server name inflating every tool on that server. An exact-case word -// wins before the case-insensitive fallback, mirroring prefix scopes. -// A query whose words name several servers stays unscoped because the -// intended scope is ambiguous. +// autoScopeFindToolsQuery scopes an unprefixed query whose words name +// exactly one cataloged server (repeats merge), so "linear issues" +// ranks like "linear: issues" rather than the server name inflating +// every tool on it. Words naming distinct servers stay unscoped as +// ambiguous. func autoScopeFindToolsQuery(servers []string, query string) scopedFindToolsQuery { words := strings.Fields(query) - // Query text is model output, so inspect only as many words as the - // per-query token cap; the nested server scan stays bounded like - // scoring. The unscoped fallback still tokenizes the full query, - // which applies the same cap itself. + // Bound model-generated words before scanning every server. Unscoped + // fallback tokenization applies its own cap. if len(words) > findToolsMaxQueryTokens { words = words[:findToolsMaxQueryTokens] } - scopeIndex := -1 - var scope scopedFindToolsQuery - for i, word := range words { + scopeServer := "" + scopeExact := false + rest := make([]string, 0, len(words)) + for _, word := range words { server, exact, ok := matchFindToolsServerWord(servers, word) if !ok { + rest = append(rest, word) continue } - if scopeIndex >= 0 { - scopeIndex = -1 - break + if scopeServer != "" && server != scopeServer { + return scopedFindToolsQuery{tokens: tokenizeFindToolsQuery(query)} } - scopeIndex = i - scope = scopedFindToolsQuery{server: server, exact: exact} + scopeServer = server + scopeExact = scopeExact || exact } - if scopeIndex < 0 { + if scopeServer == "" { return scopedFindToolsQuery{tokens: tokenizeFindToolsQuery(query)} } - scope.tokens = tokenizeFindToolsQuery(strings.Join(slices.Delete(words, scopeIndex, scopeIndex+1), " ")) - return scope + return scopedFindToolsQuery{server: scopeServer, exact: scopeExact, tokens: tokenizeFindToolsQuery(strings.Join(rest, " "))} } // matchFindToolsServerWord resolves one query word to a cataloged -// server name, exact-case first. A folded match reports exact=false so -// scoring spans case-colliding servers, exactly like a folded prefix. +// server name, exact-case first. Folded matches report exact=false so +// scoring spans case-colliding servers, like a folded prefix scope. func matchFindToolsServerWord(servers []string, word string) (server string, exact bool, ok bool) { folded := "" for _, candidate := range servers { diff --git a/coderd/x/chatd/chattool/findtools_internal_test.go b/coderd/x/chatd/chattool/findtools_internal_test.go index 23a2bb45ddc..6941756a578 100644 --- a/coderd/x/chatd/chattool/findtools_internal_test.go +++ b/coderd/x/chatd/chattool/findtools_internal_test.go @@ -155,6 +155,10 @@ func TestSearchTools(t *testing.T) { require.Len(t, result.Activated, 3, "words naming two servers leave the query unscoped") + result, _ = SearchTools(autoEntries, FindToolsArgs{Queries: []string{"linear linear issue"}}, SearchBudget{}) + require.Equal(t, []string{"linear__create_issue"}, result.Activated, + "repeated words naming the same server keep the scope") + overflowScope := strings.Repeat("issue ", findToolsMaxQueryTokens) + "linear" result, _ = SearchTools(autoEntries, FindToolsArgs{Queries: []string{overflowScope}}, SearchBudget{}) require.Len(t, result.Activated, 2, From 435c90d387f5000803967f732b799d20bba4e6a8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:38:33 +0000 Subject: [PATCH 5/7] fix(coderd/x/chatd/chattool): soften find_tools auto-scope sharp edges --- coderd/x/chatd/chattool/findtools.go | 116 ++++++++++++------ .../chatd/chattool/findtools_internal_test.go | 25 ++++ 2 files changed, 106 insertions(+), 35 deletions(-) diff --git a/coderd/x/chatd/chattool/findtools.go b/coderd/x/chatd/chattool/findtools.go index 51cfee9dd47..e3ab79090b3 100644 --- a/coderd/x/chatd/chattool/findtools.go +++ b/coderd/x/chatd/chattool/findtools.go @@ -363,35 +363,61 @@ func SearchTools(entries []FindToolCatalogEntry, args FindToolsArgs, budget Sear coverage int score int } - scored := make([]scoredEntry, 0, len(entries)) - for _, entry := range entries { - tokens := tokenizeFindToolsEntry(entry) - score := 0 - matched := make(map[string]struct{}) - for _, query := range queries { - if query.server != "" { - if query.exact && entry.Server != query.server { - continue + entryTokens := make([]findToolsEntryTokens, len(entries)) + for i, entry := range entries { + entryTokens[i] = tokenizeFindToolsEntry(entry) + } + scoreEntries := func(queries []scopedFindToolsQuery) []scoredEntry { + scored := make([]scoredEntry, 0, len(entries)) + for i, entry := range entries { + tokens := entryTokens[i] + score := 0 + matched := make(map[string]struct{}) + for _, query := range queries { + if query.server != "" { + if query.exact && entry.Server != query.server { + continue + } + if !query.exact && !strings.EqualFold(entry.Server, query.server) { + continue + } } - if !query.exact && !strings.EqualFold(entry.Server, query.server) { + if query.server != "" && len(query.tokens) == 0 { + score++ + // A scope-only hit counts as one covered term; ":" cannot occur in tokens. + matched[":"+query.server] = struct{}{} continue } + for _, token := range query.tokens { + if tokenScore := tokens.score(token); tokenScore > 0 { + score += tokenScore + matched[token] = struct{}{} + } + } } - if query.server != "" && len(query.tokens) == 0 { - score++ - // A scope-only hit counts as one covered term; ":" cannot occur in tokens. - matched[":"+query.server] = struct{}{} - continue + if score > 0 { + scored = append(scored, scoredEntry{entry: entry, coverage: len(matched), score: score}) } - for _, token := range query.tokens { - if tokenScore := tokens.score(token); tokenScore > 0 { - score += tokenScore - matched[token] = struct{}{} - } + } + return scored + } + scored := scoreEntries(queries) + // Inferred scopes are best-effort: when nothing matches under them, + // retry those queries unscoped so a server named after a capability + // word cannot hide matches on other servers. Explicit "server:" + // scopes are honored even when they match nothing. + if len(scored) == 0 { + downgraded := false + fallback := make([]scopedFindToolsQuery, len(queries)) + for i, query := range queries { + fallback[i] = query + if query.autoScoped { + fallback[i] = scopedFindToolsQuery{tokens: query.fallbackTokens} + downgraded = true } } - if score > 0 { - scored = append(scored, scoredEntry{entry: entry, coverage: len(matched), score: score}) + if downgraded { + scored = scoreEntries(fallback) } } slices.SortFunc(scored, func(a, b scoredEntry) int { @@ -460,6 +486,11 @@ type scopedFindToolsQuery struct { // servers. exact bool tokens []string + // autoScoped marks scopes inferred from server-name query words; + // fallbackTokens carries the full query so an inferred scope that + // matches nothing is retried unscoped. + autoScoped bool + fallbackTokens []string } // parseFindToolsQueries treats "server: terms" as a scope only when the @@ -537,11 +568,16 @@ func parseFindToolsQueries(entries []FindToolCatalogEntry, queries []string) []s } // autoScopeFindToolsQuery scopes an unprefixed query whose words name -// exactly one cataloged server (repeats merge), so "linear issues" -// ranks like "linear: issues" rather than the server name inflating -// every tool on it. Words naming distinct servers stay unscoped as -// ambiguous. +// exactly one cataloged server, so "linear issues" ranks like +// "linear: issues" rather than the server name inflating every tool on +// it. Words in one fold family merge: an exact-case word refines a +// folded match, and two distinct exact-case siblings span the family +// like a folded prefix scope. Words naming unrelated servers stay +// unscoped as ambiguous. func autoScopeFindToolsQuery(servers []string, query string) scopedFindToolsQuery { + unscoped := func() scopedFindToolsQuery { + return scopedFindToolsQuery{tokens: tokenizeFindToolsQuery(query)} + } words := strings.Fields(query) // Bound model-generated words before scanning every server. Unscoped // fallback tokenization applies its own cap. @@ -553,20 +589,30 @@ func autoScopeFindToolsQuery(servers []string, query string) scopedFindToolsQuer rest := make([]string, 0, len(words)) for _, word := range words { server, exact, ok := matchFindToolsServerWord(servers, word) - if !ok { + switch { + case !ok: rest = append(rest, word) - continue - } - if scopeServer != "" && server != scopeServer { - return scopedFindToolsQuery{tokens: tokenizeFindToolsQuery(query)} + case scopeServer == "": + scopeServer, scopeExact = server, exact + case !strings.EqualFold(server, scopeServer): + return unscoped() + case exact && scopeExact && server != scopeServer: + // Distinct exact-case siblings: span the fold family. + scopeExact = false + case exact: + scopeServer, scopeExact = server, true } - scopeServer = server - scopeExact = scopeExact || exact } if scopeServer == "" { - return scopedFindToolsQuery{tokens: tokenizeFindToolsQuery(query)} + return unscoped() + } + return scopedFindToolsQuery{ + server: scopeServer, + exact: scopeExact, + tokens: tokenizeFindToolsQuery(strings.Join(rest, " ")), + autoScoped: true, + fallbackTokens: tokenizeFindToolsQuery(query), } - return scopedFindToolsQuery{server: scopeServer, exact: scopeExact, tokens: tokenizeFindToolsQuery(strings.Join(rest, " "))} } // matchFindToolsServerWord resolves one query word to a cataloged diff --git a/coderd/x/chatd/chattool/findtools_internal_test.go b/coderd/x/chatd/chattool/findtools_internal_test.go index 6941756a578..4861c2dccb0 100644 --- a/coderd/x/chatd/chattool/findtools_internal_test.go +++ b/coderd/x/chatd/chattool/findtools_internal_test.go @@ -164,6 +164,21 @@ func TestSearchTools(t *testing.T) { require.Len(t, result.Activated, 2, "a server word beyond the word-inspection cap does not scope") }) + t.Run("empty auto-scope falls back to unscoped", func(t *testing.T) { + t.Parallel() + fallbackEntries := []FindToolCatalogEntry{ + {Name: "search__web", Description: "Query the web", Server: "search"}, + {Name: "tracker__find_issues", Description: "Search issues", Server: "tracker"}, + } + result, _ := SearchTools(fallbackEntries, FindToolsArgs{Queries: []string{"search issues"}}, SearchBudget{}) + require.Len(t, result.Matches, 2, + "a server-name word that scopes to nothing relevant retries unscoped") + require.Equal(t, "tracker__find_issues", result.Matches[0].Name) + + explicit, _ := SearchTools(fallbackEntries, FindToolsArgs{Queries: []string{"search: issues"}}, SearchBudget{}) + require.Empty(t, explicit.Matches, + "an explicit scope that matches nothing does not fall back") + }) t.Run("server prefix scope", func(t *testing.T) { t.Parallel() scopedEntries := []FindToolCatalogEntry{ @@ -211,6 +226,16 @@ func TestSearchTools(t *testing.T) { result, _ = SearchTools(caseEntries, FindToolsArgs{Queries: []string{"GITHUB status"}}, SearchBudget{}) require.Len(t, result.Activated, 2, "a server word matching no exact-case name spans the case-colliding servers") + + result, _ = SearchTools(caseEntries, FindToolsArgs{Queries: []string{"GITHUB github status"}}, SearchBudget{}) + require.Equal(t, []string{"github__get_commit"}, result.Activated, + "an exact-case word refines a folded word from the same fold family") + + spanEntries := append(slices.Clone(caseEntries), + FindToolCatalogEntry{Name: "ci__status", Description: "Pipeline status", Server: "ci"}) + result, _ = SearchTools(spanEntries, FindToolsArgs{Queries: []string{"GitHub github status"}}, SearchBudget{}) + require.Len(t, result.Activated, 2, + "distinct exact-case sibling words span their fold family, not the whole catalog") }) t.Run("folded scopes with different byte lengths", func(t *testing.T) { t.Parallel() From 4e719f93d298951a04a03b6689ae9726bb3676e1 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:49:37 +0000 Subject: [PATCH 6/7] fix(coderd/x/chatd/chattool): make find_tools scope fallback per query and span sticky --- coderd/x/chatd/chattool/findtools.go | 50 +++++++++++-------- .../chatd/chattool/findtools_internal_test.go | 10 ++++ 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/coderd/x/chatd/chattool/findtools.go b/coderd/x/chatd/chattool/findtools.go index e3ab79090b3..43e0db0e8c1 100644 --- a/coderd/x/chatd/chattool/findtools.go +++ b/coderd/x/chatd/chattool/findtools.go @@ -367,13 +367,14 @@ func SearchTools(entries []FindToolCatalogEntry, args FindToolsArgs, budget Sear for i, entry := range entries { entryTokens[i] = tokenizeFindToolsEntry(entry) } - scoreEntries := func(queries []scopedFindToolsQuery) []scoredEntry { + scoreEntries := func(queries []scopedFindToolsQuery) ([]scoredEntry, []bool) { scored := make([]scoredEntry, 0, len(entries)) + contributed := make([]bool, len(queries)) for i, entry := range entries { tokens := entryTokens[i] score := 0 matched := make(map[string]struct{}) - for _, query := range queries { + for queryIndex, query := range queries { if query.server != "" { if query.exact && entry.Server != query.server { continue @@ -386,12 +387,14 @@ func SearchTools(entries []FindToolCatalogEntry, args FindToolsArgs, budget Sear score++ // A scope-only hit counts as one covered term; ":" cannot occur in tokens. matched[":"+query.server] = struct{}{} + contributed[queryIndex] = true continue } for _, token := range query.tokens { if tokenScore := tokens.score(token); tokenScore > 0 { score += tokenScore matched[token] = struct{}{} + contributed[queryIndex] = true } } } @@ -399,27 +402,26 @@ func SearchTools(entries []FindToolCatalogEntry, args FindToolsArgs, budget Sear scored = append(scored, scoredEntry{entry: entry, coverage: len(matched), score: score}) } } - return scored - } - scored := scoreEntries(queries) - // Inferred scopes are best-effort: when nothing matches under them, - // retry those queries unscoped so a server named after a capability - // word cannot hide matches on other servers. Explicit "server:" - // scopes are honored even when they match nothing. - if len(scored) == 0 { - downgraded := false - fallback := make([]scopedFindToolsQuery, len(queries)) - for i, query := range queries { - fallback[i] = query - if query.autoScoped { - fallback[i] = scopedFindToolsQuery{tokens: query.fallbackTokens} - downgraded = true - } - } - if downgraded { - scored = scoreEntries(fallback) + return scored, contributed + } + scored, contributed := scoreEntries(queries) + // Inferred scopes are best-effort: each auto-scoped query that + // matched nothing retries unscoped, independently of its siblings, + // so a server named after a capability word cannot hide matches on + // other servers. Explicit "server:" scopes are honored even when + // they match nothing. + fallback := make([]scopedFindToolsQuery, len(queries)) + downgraded := false + for i, query := range queries { + fallback[i] = query + if query.autoScoped && !contributed[i] { + fallback[i] = scopedFindToolsQuery{tokens: query.fallbackTokens} + downgraded = true } } + if downgraded { + scored, _ = scoreEntries(fallback) + } slices.SortFunc(scored, func(a, b scoredEntry) int { if a.coverage != b.coverage { return b.coverage - a.coverage @@ -586,6 +588,9 @@ func autoScopeFindToolsQuery(servers []string, query string) scopedFindToolsQuer } scopeServer := "" scopeExact := false + // spanned pins the scope to the whole fold family once distinct + // exact-case siblings are named; later repeats cannot re-narrow it. + spanned := false rest := make([]string, 0, len(words)) for _, word := range words { server, exact, ok := matchFindToolsServerWord(servers, word) @@ -599,7 +604,8 @@ func autoScopeFindToolsQuery(servers []string, query string) scopedFindToolsQuer case exact && scopeExact && server != scopeServer: // Distinct exact-case siblings: span the fold family. scopeExact = false - case exact: + spanned = true + case exact && !spanned: scopeServer, scopeExact = server, true } } diff --git a/coderd/x/chatd/chattool/findtools_internal_test.go b/coderd/x/chatd/chattool/findtools_internal_test.go index 4861c2dccb0..c4e4959b12d 100644 --- a/coderd/x/chatd/chattool/findtools_internal_test.go +++ b/coderd/x/chatd/chattool/findtools_internal_test.go @@ -169,6 +169,7 @@ func TestSearchTools(t *testing.T) { fallbackEntries := []FindToolCatalogEntry{ {Name: "search__web", Description: "Query the web", Server: "search"}, {Name: "tracker__find_issues", Description: "Search issues", Server: "tracker"}, + {Name: "calendar__list_events", Description: "List events", Server: "calendar"}, } result, _ := SearchTools(fallbackEntries, FindToolsArgs{Queries: []string{"search issues"}}, SearchBudget{}) require.Len(t, result.Matches, 2, @@ -178,6 +179,11 @@ func TestSearchTools(t *testing.T) { explicit, _ := SearchTools(fallbackEntries, FindToolsArgs{Queries: []string{"search: issues"}}, SearchBudget{}) require.Empty(t, explicit.Matches, "an explicit scope that matches nothing does not fall back") + + multi, _ := SearchTools(fallbackEntries, FindToolsArgs{Queries: []string{"search issues", "calendar events"}}, SearchBudget{}) + require.Contains(t, multi.Activated, "calendar__list_events") + require.Contains(t, multi.Activated, "tracker__find_issues", + "an empty inferred scope falls back even when a sibling query matched") }) t.Run("server prefix scope", func(t *testing.T) { t.Parallel() @@ -236,6 +242,10 @@ func TestSearchTools(t *testing.T) { result, _ = SearchTools(spanEntries, FindToolsArgs{Queries: []string{"GitHub github status"}}, SearchBudget{}) require.Len(t, result.Activated, 2, "distinct exact-case sibling words span their fold family, not the whole catalog") + + result, _ = SearchTools(caseEntries, FindToolsArgs{Queries: []string{"GitHub github GitHub status"}}, SearchBudget{}) + require.Len(t, result.Activated, 2, + "a repeated exact sibling cannot re-narrow a spanned fold family") }) t.Run("folded scopes with different byte lengths", func(t *testing.T) { t.Parallel() From 09945387951a17d2ed28a05c4be76dedf2d4f541 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:39:22 +0000 Subject: [PATCH 7/7] refactor(coderd/x/chatd/chattool): drop find_tools server-name auto-scoping Inferring scope from unprefixed query words kept accumulating edge cases (word-inspection caps, repeat merging, fold-family spans, per-query fallback) across review rounds. Coverage-first ranking already keeps a server name from deciding rank, and explicit "server:" prefixes remain for deliberate scoping, so the inference no longer carries its weight. Queries without a recognized prefix now always search unscoped. --- coderd/x/chatd/chattool/findtools.go | 148 +++--------------- .../chatd/chattool/findtools_internal_test.go | 75 --------- 2 files changed, 24 insertions(+), 199 deletions(-) diff --git a/coderd/x/chatd/chattool/findtools.go b/coderd/x/chatd/chattool/findtools.go index 43e0db0e8c1..031a6912bbc 100644 --- a/coderd/x/chatd/chattool/findtools.go +++ b/coderd/x/chatd/chattool/findtools.go @@ -367,61 +367,37 @@ func SearchTools(entries []FindToolCatalogEntry, args FindToolsArgs, budget Sear for i, entry := range entries { entryTokens[i] = tokenizeFindToolsEntry(entry) } - scoreEntries := func(queries []scopedFindToolsQuery) ([]scoredEntry, []bool) { - scored := make([]scoredEntry, 0, len(entries)) - contributed := make([]bool, len(queries)) - for i, entry := range entries { - tokens := entryTokens[i] - score := 0 - matched := make(map[string]struct{}) - for queryIndex, query := range queries { - if query.server != "" { - if query.exact && entry.Server != query.server { - continue - } - if !query.exact && !strings.EqualFold(entry.Server, query.server) { - continue - } - } - if query.server != "" && len(query.tokens) == 0 { - score++ - // A scope-only hit counts as one covered term; ":" cannot occur in tokens. - matched[":"+query.server] = struct{}{} - contributed[queryIndex] = true + scored := make([]scoredEntry, 0, len(entries)) + for i, entry := range entries { + tokens := entryTokens[i] + score := 0 + matched := make(map[string]struct{}) + for _, query := range queries { + if query.server != "" { + if query.exact && entry.Server != query.server { continue } - for _, token := range query.tokens { - if tokenScore := tokens.score(token); tokenScore > 0 { - score += tokenScore - matched[token] = struct{}{} - contributed[queryIndex] = true - } + if !query.exact && !strings.EqualFold(entry.Server, query.server) { + continue } } - if score > 0 { - scored = append(scored, scoredEntry{entry: entry, coverage: len(matched), score: score}) + if query.server != "" && len(query.tokens) == 0 { + score++ + // A scope-only hit counts as one covered term; ":" cannot occur in tokens. + matched[":"+query.server] = struct{}{} + continue + } + for _, token := range query.tokens { + if tokenScore := tokens.score(token); tokenScore > 0 { + score += tokenScore + matched[token] = struct{}{} + } } } - return scored, contributed - } - scored, contributed := scoreEntries(queries) - // Inferred scopes are best-effort: each auto-scoped query that - // matched nothing retries unscoped, independently of its siblings, - // so a server named after a capability word cannot hide matches on - // other servers. Explicit "server:" scopes are honored even when - // they match nothing. - fallback := make([]scopedFindToolsQuery, len(queries)) - downgraded := false - for i, query := range queries { - fallback[i] = query - if query.autoScoped && !contributed[i] { - fallback[i] = scopedFindToolsQuery{tokens: query.fallbackTokens} - downgraded = true + if score > 0 { + scored = append(scored, scoredEntry{entry: entry, coverage: len(matched), score: score}) } } - if downgraded { - scored, _ = scoreEntries(fallback) - } slices.SortFunc(scored, func(a, b scoredEntry) int { if a.coverage != b.coverage { return b.coverage - a.coverage @@ -488,11 +464,6 @@ type scopedFindToolsQuery struct { // servers. exact bool tokens []string - // autoScoped marks scopes inferred from server-name query words; - // fallbackTokens carries the full query so an inferred scope that - // matches nothing is retried unscoped. - autoScoped bool - fallbackTokens []string } // parseFindToolsQueries treats "server: terms" as a scope only when the @@ -563,83 +534,12 @@ func parseFindToolsQueries(entries []FindToolCatalogEntry, queries []string) []s } } if !scoped { - parsed = append(parsed, autoScopeFindToolsQuery(servers, query)) + parsed = append(parsed, scopedFindToolsQuery{tokens: tokenizeFindToolsQuery(query)}) } } return parsed } -// autoScopeFindToolsQuery scopes an unprefixed query whose words name -// exactly one cataloged server, so "linear issues" ranks like -// "linear: issues" rather than the server name inflating every tool on -// it. Words in one fold family merge: an exact-case word refines a -// folded match, and two distinct exact-case siblings span the family -// like a folded prefix scope. Words naming unrelated servers stay -// unscoped as ambiguous. -func autoScopeFindToolsQuery(servers []string, query string) scopedFindToolsQuery { - unscoped := func() scopedFindToolsQuery { - return scopedFindToolsQuery{tokens: tokenizeFindToolsQuery(query)} - } - words := strings.Fields(query) - // Bound model-generated words before scanning every server. Unscoped - // fallback tokenization applies its own cap. - if len(words) > findToolsMaxQueryTokens { - words = words[:findToolsMaxQueryTokens] - } - scopeServer := "" - scopeExact := false - // spanned pins the scope to the whole fold family once distinct - // exact-case siblings are named; later repeats cannot re-narrow it. - spanned := false - rest := make([]string, 0, len(words)) - for _, word := range words { - server, exact, ok := matchFindToolsServerWord(servers, word) - switch { - case !ok: - rest = append(rest, word) - case scopeServer == "": - scopeServer, scopeExact = server, exact - case !strings.EqualFold(server, scopeServer): - return unscoped() - case exact && scopeExact && server != scopeServer: - // Distinct exact-case siblings: span the fold family. - scopeExact = false - spanned = true - case exact && !spanned: - scopeServer, scopeExact = server, true - } - } - if scopeServer == "" { - return unscoped() - } - return scopedFindToolsQuery{ - server: scopeServer, - exact: scopeExact, - tokens: tokenizeFindToolsQuery(strings.Join(rest, " ")), - autoScoped: true, - fallbackTokens: tokenizeFindToolsQuery(query), - } -} - -// matchFindToolsServerWord resolves one query word to a cataloged -// server name, exact-case first. Folded matches report exact=false so -// scoring spans case-colliding servers, like a folded prefix scope. -func matchFindToolsServerWord(servers []string, word string) (server string, exact bool, ok bool) { - folded := "" - for _, candidate := range servers { - if word == candidate { - return candidate, true, true - } - if folded == "" && strings.EqualFold(word, candidate) { - folded = candidate - } - } - if folded != "" { - return folded, false, true - } - return "", false, false -} - // cutPrefixFold is a case-insensitive strings.CutPrefix. It compares // rune by rune with the same simple folding as strings.EqualFold, so a // prefix whose folded form differs in UTF-8 byte length (like S and diff --git a/coderd/x/chatd/chattool/findtools_internal_test.go b/coderd/x/chatd/chattool/findtools_internal_test.go index c4e4959b12d..b4e9b32ef98 100644 --- a/coderd/x/chatd/chattool/findtools_internal_test.go +++ b/coderd/x/chatd/chattool/findtools_internal_test.go @@ -132,59 +132,6 @@ func TestSearchTools(t *testing.T) { require.Equal(t, "tracker__update", result.Matches[0].Name, "an entry matching more distinct query terms outranks a higher single-term score") }) - t.Run("server-name query words scope automatically", func(t *testing.T) { - t.Parallel() - autoEntries := []FindToolCatalogEntry{ - {Name: "linear__create_issue", Description: "Create an issue", Server: "linear"}, - {Name: "linear__list_teams", Description: "List teams", Server: "linear"}, - {Name: "github__create_issue", Description: "Create an issue imported from linear", Server: "github"}, - } - result, _ := SearchTools(autoEntries, FindToolsArgs{Queries: []string{"linear issue"}}, SearchBudget{}) - require.Equal(t, []string{"linear__create_issue"}, result.Activated, - "a query word naming a server scopes the query to that server") - - result, _ = SearchTools(autoEntries, FindToolsArgs{Queries: []string{"Linear issue"}}, SearchBudget{}) - require.Equal(t, []string{"linear__create_issue"}, result.Activated, - "a case-variant server word still scopes") - - result, _ = SearchTools(autoEntries, FindToolsArgs{Queries: []string{"linear"}}, SearchBudget{}) - require.Equal(t, []string{"linear__create_issue", "linear__list_teams"}, result.Activated, - "a bare server-name query lists that server's tools without cross-server description hits") - - result, _ = SearchTools(autoEntries, FindToolsArgs{Queries: []string{"linear github issue"}}, SearchBudget{}) - require.Len(t, result.Activated, 3, - "words naming two servers leave the query unscoped") - - result, _ = SearchTools(autoEntries, FindToolsArgs{Queries: []string{"linear linear issue"}}, SearchBudget{}) - require.Equal(t, []string{"linear__create_issue"}, result.Activated, - "repeated words naming the same server keep the scope") - - overflowScope := strings.Repeat("issue ", findToolsMaxQueryTokens) + "linear" - result, _ = SearchTools(autoEntries, FindToolsArgs{Queries: []string{overflowScope}}, SearchBudget{}) - require.Len(t, result.Activated, 2, - "a server word beyond the word-inspection cap does not scope") - }) - t.Run("empty auto-scope falls back to unscoped", func(t *testing.T) { - t.Parallel() - fallbackEntries := []FindToolCatalogEntry{ - {Name: "search__web", Description: "Query the web", Server: "search"}, - {Name: "tracker__find_issues", Description: "Search issues", Server: "tracker"}, - {Name: "calendar__list_events", Description: "List events", Server: "calendar"}, - } - result, _ := SearchTools(fallbackEntries, FindToolsArgs{Queries: []string{"search issues"}}, SearchBudget{}) - require.Len(t, result.Matches, 2, - "a server-name word that scopes to nothing relevant retries unscoped") - require.Equal(t, "tracker__find_issues", result.Matches[0].Name) - - explicit, _ := SearchTools(fallbackEntries, FindToolsArgs{Queries: []string{"search: issues"}}, SearchBudget{}) - require.Empty(t, explicit.Matches, - "an explicit scope that matches nothing does not fall back") - - multi, _ := SearchTools(fallbackEntries, FindToolsArgs{Queries: []string{"search issues", "calendar events"}}, SearchBudget{}) - require.Contains(t, multi.Activated, "calendar__list_events") - require.Contains(t, multi.Activated, "tracker__find_issues", - "an empty inferred scope falls back even when a sibling query matched") - }) t.Run("server prefix scope", func(t *testing.T) { t.Parallel() scopedEntries := []FindToolCatalogEntry{ @@ -224,28 +171,6 @@ func TestSearchTools(t *testing.T) { result, _ = SearchTools(caseEntries, FindToolsArgs{Queries: []string{"GITHUB: status"}}, SearchBudget{}) require.Len(t, result.Activated, 2, "a prefix matching no exact-case name falls back to spanning the case-colliding servers") - - result, _ = SearchTools(caseEntries, FindToolsArgs{Queries: []string{"GitHub status"}}, SearchBudget{}) - require.Equal(t, []string{"GitHub__enterprise_status"}, result.Activated, - "an exact-case server word auto-scopes only to its own server") - - result, _ = SearchTools(caseEntries, FindToolsArgs{Queries: []string{"GITHUB status"}}, SearchBudget{}) - require.Len(t, result.Activated, 2, - "a server word matching no exact-case name spans the case-colliding servers") - - result, _ = SearchTools(caseEntries, FindToolsArgs{Queries: []string{"GITHUB github status"}}, SearchBudget{}) - require.Equal(t, []string{"github__get_commit"}, result.Activated, - "an exact-case word refines a folded word from the same fold family") - - spanEntries := append(slices.Clone(caseEntries), - FindToolCatalogEntry{Name: "ci__status", Description: "Pipeline status", Server: "ci"}) - result, _ = SearchTools(spanEntries, FindToolsArgs{Queries: []string{"GitHub github status"}}, SearchBudget{}) - require.Len(t, result.Activated, 2, - "distinct exact-case sibling words span their fold family, not the whole catalog") - - result, _ = SearchTools(caseEntries, FindToolsArgs{Queries: []string{"GitHub github GitHub status"}}, SearchBudget{}) - require.Len(t, result.Activated, 2, - "a repeated exact sibling cannot re-narrow a spanned fold family") }) t.Run("folded scopes with different byte lengths", func(t *testing.T) { t.Parallel()