Skip to content

Commit 93a4942

Browse files
committed
fix(agent): match tool names across a trailing plural s
1 parent 69459ba commit 93a4942

3 files changed

Lines changed: 194 additions & 4 deletions

File tree

‎src/agent/lexical-rank.test.ts‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,98 @@ describe("scoreLexical", () => {
9999
}
100100
});
101101
});
102+
103+
describe("scoreLexical tool names across a trailing plural s", () => {
104+
// No fixture name contains this raw query whole, so only the per-token
105+
// tiers differ between the scores compared.
106+
const rawQuery = "linear issue";
107+
108+
function nameScore(name: string, query: string): number {
109+
return scoreLexical(
110+
lexicalFields(name, "", NO_LEXICAL_PARAMS),
111+
tokenizeLexical(query),
112+
rawQuery,
113+
);
114+
}
115+
116+
// `substring` holds the query word only inside a longer name token.
117+
function expectFoldedBetween(
118+
query: string,
119+
names: { substring: string; folded: string; exact: string },
120+
) {
121+
const folded = nameScore(names.folded, query);
122+
expect(folded).toBeGreaterThan(nameScore(names.substring, query));
123+
expect(folded).toBeLessThan(nameScore(names.exact, query));
124+
}
125+
126+
test("a singular query word matches its plural in a name", () => {
127+
expectFoldedBetween("issue", {
128+
substring: "mcp__acme__issuetracker",
129+
folded: "mcp__linear__list_issues",
130+
exact: "mcp__linear__list_issue",
131+
});
132+
});
133+
134+
test("a plural query word matches its singular in a name", () => {
135+
expectFoldedBetween("issues", {
136+
substring: "mcp__acme__issuestracker",
137+
folded: "mcp__linear__get_issue",
138+
exact: "mcp__linear__get_issues",
139+
});
140+
});
141+
142+
test("a word given in both forms counts once, as its better spelling", () => {
143+
const fields = lexicalFields(
144+
"mcp__linear__list_issues",
145+
"List issues in Linear",
146+
NO_LEXICAL_PARAMS,
147+
);
148+
expect(
149+
scoreLexical(fields, tokenizeLexical("linear issue issues"), rawQuery),
150+
).toBe(scoreLexical(fields, tokenizeLexical("linear issues"), rawQuery));
151+
});
152+
153+
test("a word given in both forms scores the same in either order", () => {
154+
// "issue" sits only inside "issuetracker", so only a substring tier
155+
// matches, and it matches the singular but not the plural.
156+
const fields = lexicalFields(
157+
"mcp__acme__issuetracker",
158+
"",
159+
NO_LEXICAL_PARAMS,
160+
);
161+
const pluralFirst = scoreLexical(
162+
fields,
163+
tokenizeLexical("issues issue"),
164+
rawQuery,
165+
);
166+
expect(pluralFirst).toBeGreaterThan(0);
167+
expect(pluralFirst).toBe(
168+
scoreLexical(fields, tokenizeLexical("issue issues"), rawQuery),
169+
);
170+
});
171+
172+
test("a word of three letters keeps its trailing s", () => {
173+
const fields = lexicalFields("mcp__acme__get_it", "", NO_LEXICAL_PARAMS);
174+
expect(scoreLexical(fields, tokenizeLexical("its"), "its")).toBe(0);
175+
});
176+
177+
test("a plural query word earns no substring credit for its singular", () => {
178+
// "users" folds to "user", which sits inside "username"; the substring
179+
// tier still looks for "users".
180+
const fields = lexicalFields(
181+
"mcp__acme__username_lookup",
182+
"",
183+
NO_LEXICAL_PARAMS,
184+
);
185+
expect(scoreLexical(fields, tokenizeLexical("users"), "users")).toBe(0);
186+
});
187+
188+
test("a description word does not match a query word that folds to it", () => {
189+
const fields = lexicalFields(
190+
"mcp__linear__save_issue",
191+
"Create a new issue",
192+
NO_LEXICAL_PARAMS,
193+
);
194+
expect(scoreLexical(fields, tokenizeLexical("news"), "news")).toBe(0);
195+
});
196+
});

‎src/agent/lexical-rank.ts‎

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// One shared dependency-free lexical ranker behind tool_search,
22
// search_agents, and skill_search. Exact name-token hits weigh most, then
3+
// name tokens that match across a trailing plural "s" (so "issue" finds
4+
// list_issues while the spelling the query uses still wins ties), then
35
// blob-token hits, then a raw name substring (so "linear" finds
46
// mcp__linear__* even though it is not a whole token there), then a raw blob
57
// substring. Parameter name and description tokens (tool_search only) weigh
@@ -11,10 +13,21 @@ export function tokenizeLexical(text: string): string[] {
1113
return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
1214
}
1315

16+
// Folding also merges real word pairs such as "news" and "new", so it stays
17+
// on names: in descriptions, a common word like "new" would turn a miss into
18+
// a false hit. Three-letter words keep their "s", so "its" never becomes "it".
19+
function foldPlural(token: string): string {
20+
return token.length > 3 && token.endsWith("s") ? token.slice(0, -1) : token;
21+
}
22+
23+
// Under exact (3); 3 + this clears TOOL_SEARCH_SCORE_RATIO * 6 ("issue list").
24+
const FOLDED_NAME_TOKEN_SCORE = 2.5;
25+
1426
export interface LexicalFields {
1527
readonly name: string;
1628
readonly blob: string;
1729
readonly nameTokens: readonly string[];
30+
readonly foldedNameTokens: readonly string[];
1831
readonly blobTokens: ReadonlySet<string>;
1932
readonly paramNameTokens: ReadonlySet<string>;
2033
readonly paramBlobTokens: ReadonlySet<string>;
@@ -40,10 +53,12 @@ export function lexicalFields(
4053
blob: string,
4154
params: LexicalParams,
4255
): LexicalFields {
56+
const nameTokens = tokenizeLexical(name);
4357
return {
4458
name,
4559
blob,
46-
nameTokens: tokenizeLexical(name),
60+
nameTokens,
61+
foldedNameTokens: nameTokens.map(foldPlural),
4762
blobTokens: new Set(tokenizeLexical(blob)),
4863
paramNameTokens: new Set(
4964
tokenizeLexical(params.names.map(splitCamelCase).join(" ")),
@@ -59,6 +74,9 @@ function directTokenScore(
5974
token: string,
6075
): number {
6176
if (fields.nameTokens.includes(token)) return 3;
77+
if (fields.foldedNameTokens.includes(foldPlural(token))) {
78+
return FOLDED_NAME_TOKEN_SCORE;
79+
}
6280
if (fields.blobTokens.has(token)) return 1;
6381
if (name.includes(token)) return 0.75;
6482
if (blob.includes(token)) return 0.25;
@@ -82,11 +100,26 @@ export function scoreLexical(
82100
let total = matched;
83101
// A model repeats a word because its sentence needs it ("release ID or
84102
// release slug"), not to weight it; counting each copy multiplies the
85-
// bonus for every tool that matches that one word.
103+
// bonus for every tool that matches that one word. "issue" and "issues"
104+
// are one word, scored as whichever spelling matches better. The merge
105+
// applies on every tier, so an unrelated pair like "new" and "news" also
106+
// counts once against a description; that is accepted.
107+
const words = new Map<string, string[]>();
86108
for (const token of new Set(queryTokens)) {
87-
const direct = directTokenScore(fields, name, blob, token);
109+
const folded = foldPlural(token);
110+
const spellings = words.get(folded);
111+
if (spellings === undefined) words.set(folded, [token]);
112+
else spellings.push(token);
113+
}
114+
for (const spellings of words.values()) {
115+
const direct = Math.max(
116+
...spellings.map((token) => directTokenScore(fields, name, blob, token)),
117+
);
118+
const param = Math.max(
119+
...spellings.map((token) => paramTokenScore(fields, token)),
120+
);
88121
matched += direct;
89-
total += Math.max(direct, paramTokenScore(fields, token));
122+
total += Math.max(direct, param);
90123
}
91124
return matched > 0 ? total : 0;
92125
}

‎src/agent/tool-search.test.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,68 @@ describe("createToolIndex", () => {
467467
expect(teamIndex.search("linear team")[0]).toBe("mcp__linear__list_teams");
468468
});
469469

470+
// Trimmed from the live Linear MCP catalog and sorted by name, so five
471+
// tools that carry the singular "issue" come ahead of list_issues.
472+
const linearIssueCatalog: ToolDefinition[] = [
473+
mcpDef(
474+
"mcp__linear__create_issue_label",
475+
"[linear] Create a new Linear issue label. Deprecated: use `save_issue_label`, which can also update labels.",
476+
),
477+
mcpDef(
478+
"mcp__linear__get_issue",
479+
"[linear] Retrieve detailed information about an issue by ID, including attachments, git branch name, and active Triage Intelligence suggestions when the issue is in triage.",
480+
),
481+
mcpDef(
482+
"mcp__linear__get_issue_status",
483+
"[linear] Retrieve detailed information about an issue status in Linear by name or ID",
484+
),
485+
mcpDef(
486+
"mcp__linear__list_comments",
487+
"[linear] List comments on a Linear issue, project, initiative, document, project milestone, or project/initiative status update.",
488+
),
489+
mcpDef(
490+
"mcp__linear__list_issue_labels",
491+
"[linear] List available issue labels in a Linear workspace or team. A retired label carries `retiredAt` and cannot be applied to new issues.",
492+
),
493+
mcpDef(
494+
"mcp__linear__list_issue_statuses",
495+
"[linear] List available issue statuses in a Linear team",
496+
),
497+
mcpDef(
498+
"mcp__linear__list_issues",
499+
"[linear] List issues in the user's Linear workspace, including active Triage Intelligence suggestions for issues in triage.",
500+
),
501+
mcpDef(
502+
"mcp__linear__list_projects",
503+
"[linear] List projects in the user's Linear workspace",
504+
),
505+
mcpDef(
506+
"mcp__linear__list_teams",
507+
"[linear] List teams in the user's Linear workspace, including their parent IDs and hierarchy names",
508+
),
509+
mcpDef(
510+
"mcp__linear__save_issue",
511+
"[linear] Create or update a Linear issue. If `id` is provided, updates the existing issue; otherwise creates a new one.",
512+
),
513+
];
514+
const linearIssueIndexes = [
515+
createToolIndex(() => linearIssueCatalog),
516+
createToolIndex(() => [...linearIssueCatalog].reverse()),
517+
];
518+
519+
test("a singular query word finds the tool named for its plural", () => {
520+
for (const index of linearIssueIndexes) {
521+
expect(index.search("issue list")).toContain("mcp__linear__list_issues");
522+
}
523+
});
524+
525+
test("a plural query word ranks the tool named for it first", () => {
526+
for (const index of linearIssueIndexes) {
527+
expect(index.search("list issues")[0]).toBe("mcp__linear__list_issues");
528+
expect(index.search("linear issues")[0]).toBe("mcp__linear__list_issues");
529+
}
530+
});
531+
470532
test("a malformed input schema still scores on name and description", () => {
471533
const malformedIndex = createToolIndex(() => [
472534
{

0 commit comments

Comments
 (0)