Skip to content

Commit f96a9c2

Browse files
committed
fix(agent): match tool names across a trailing plural s
1 parent 1e1ff30 commit f96a9c2

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>;
@@ -32,10 +45,12 @@ export function lexicalFields(
3245
blob: string,
3346
params: LexicalParams,
3447
): LexicalFields {
48+
const nameTokens = tokenizeLexical(name);
3549
return {
3650
name,
3751
blob,
38-
nameTokens: tokenizeLexical(name),
52+
nameTokens,
53+
foldedNameTokens: nameTokens.map(foldPlural),
3954
blobTokens: new Set(tokenizeLexical(blob)),
4055
paramNameTokens: new Set(tokenizeLexical(params.names.join(" "))),
4156
paramBlobTokens: new Set(tokenizeLexical(params.blob)),
@@ -49,6 +64,9 @@ function directTokenScore(
4964
token: string,
5065
): number {
5166
if (fields.nameTokens.includes(token)) return 3;
67+
if (fields.foldedNameTokens.includes(foldPlural(token))) {
68+
return FOLDED_NAME_TOKEN_SCORE;
69+
}
5270
if (fields.blobTokens.has(token)) return 1;
5371
if (name.includes(token)) return 0.75;
5472
if (blob.includes(token)) return 0.25;
@@ -72,11 +90,26 @@ export function scoreLexical(
7290
let total = matched;
7391
// A model repeats a word because its sentence needs it ("release ID or
7492
// release slug"), not to weight it; counting each copy multiplies the
75-
// bonus for every tool that matches that one word.
93+
// bonus for every tool that matches that one word. "issue" and "issues"
94+
// are one word, scored as whichever spelling matches better. The merge
95+
// applies on every tier, so an unrelated pair like "new" and "news" also
96+
// counts once against a description; that is accepted.
97+
const words = new Map<string, string[]>();
7698
for (const token of new Set(queryTokens)) {
77-
const direct = directTokenScore(fields, name, blob, token);
99+
const folded = foldPlural(token);
100+
const spellings = words.get(folded);
101+
if (spellings === undefined) words.set(folded, [token]);
102+
else spellings.push(token);
103+
}
104+
for (const spellings of words.values()) {
105+
const direct = Math.max(
106+
...spellings.map((token) => directTokenScore(fields, name, blob, token)),
107+
);
108+
const param = Math.max(
109+
...spellings.map((token) => paramTokenScore(fields, token)),
110+
);
78111
matched += direct;
79-
total += Math.max(direct, paramTokenScore(fields, token));
112+
total += Math.max(direct, param);
80113
}
81114
return matched > 0 ? total : 0;
82115
}

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

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

453+
// Trimmed from the live Linear MCP catalog and sorted by name, so five
454+
// tools that carry the singular "issue" come ahead of list_issues.
455+
const linearIssueCatalog: ToolDefinition[] = [
456+
mcpDef(
457+
"mcp__linear__create_issue_label",
458+
"[linear] Create a new Linear issue label. Deprecated: use `save_issue_label`, which can also update labels.",
459+
),
460+
mcpDef(
461+
"mcp__linear__get_issue",
462+
"[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.",
463+
),
464+
mcpDef(
465+
"mcp__linear__get_issue_status",
466+
"[linear] Retrieve detailed information about an issue status in Linear by name or ID",
467+
),
468+
mcpDef(
469+
"mcp__linear__list_comments",
470+
"[linear] List comments on a Linear issue, project, initiative, document, project milestone, or project/initiative status update.",
471+
),
472+
mcpDef(
473+
"mcp__linear__list_issue_labels",
474+
"[linear] List available issue labels in a Linear workspace or team. A retired label carries `retiredAt` and cannot be applied to new issues.",
475+
),
476+
mcpDef(
477+
"mcp__linear__list_issue_statuses",
478+
"[linear] List available issue statuses in a Linear team",
479+
),
480+
mcpDef(
481+
"mcp__linear__list_issues",
482+
"[linear] List issues in the user's Linear workspace, including active Triage Intelligence suggestions for issues in triage.",
483+
),
484+
mcpDef(
485+
"mcp__linear__list_projects",
486+
"[linear] List projects in the user's Linear workspace",
487+
),
488+
mcpDef(
489+
"mcp__linear__list_teams",
490+
"[linear] List teams in the user's Linear workspace, including their parent IDs and hierarchy names",
491+
),
492+
mcpDef(
493+
"mcp__linear__save_issue",
494+
"[linear] Create or update a Linear issue. If `id` is provided, updates the existing issue; otherwise creates a new one.",
495+
),
496+
];
497+
const linearIssueIndexes = [
498+
createToolIndex(() => linearIssueCatalog),
499+
createToolIndex(() => [...linearIssueCatalog].reverse()),
500+
];
501+
502+
test("a singular query word finds the tool named for its plural", () => {
503+
for (const index of linearIssueIndexes) {
504+
expect(index.search("issue list")).toContain("mcp__linear__list_issues");
505+
}
506+
});
507+
508+
test("a plural query word ranks the tool named for it first", () => {
509+
for (const index of linearIssueIndexes) {
510+
expect(index.search("list issues")[0]).toBe("mcp__linear__list_issues");
511+
expect(index.search("linear issues")[0]).toBe("mcp__linear__list_issues");
512+
}
513+
});
514+
453515
test("a malformed input schema still scores on name and description", () => {
454516
const malformedIndex = createToolIndex(() => [
455517
{

0 commit comments

Comments
 (0)