Skip to content

Commit eef2bd2

Browse files
committed
fix(agent): match tool_search queries against tool parameters
1 parent 6f2ee7d commit eef2bd2

8 files changed

Lines changed: 327 additions & 34 deletions

‎src/agent/agent-search.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { type } from "arktype";
55
import { scrubSecretShapedContent } from "../plugins/tool-result-secret-scrub.js";
66
import {
77
lexicalFields,
8+
NO_LEXICAL_PARAMS,
89
rankAndCut,
910
scoreLexical,
1011
tokenizeLexical,
@@ -35,7 +36,7 @@ export function createAgentIndex(
3536
rawQuery: string,
3637
): number =>
3738
scoreLexical(
38-
lexicalFields(profile.id, profileSearchText(profile)),
39+
lexicalFields(profile.id, profileSearchText(profile), NO_LEXICAL_PARAMS),
3940
queryTokens,
4041
rawQuery,
4142
) +

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

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
22

33
import {
44
lexicalFields,
5+
NO_LEXICAL_PARAMS,
56
scoreLexical,
67
tokenizeLexical,
78
} from "./lexical-rank.js";
@@ -11,6 +12,7 @@ describe("scoreLexical", () => {
1112
const fields = lexicalFields(
1213
"mcp__linear__list_release_notes",
1314
"List release notes in the workspace",
15+
NO_LEXICAL_PARAMS,
1416
);
1517
// Same raw query on both sides so only the token list differs; the raw
1618
// query feeds a separate whole-query substring bonus.
@@ -19,4 +21,81 @@ describe("scoreLexical", () => {
1921
scoreLexical(fields, tokenizeLexical("release notes release"), rawQuery),
2022
).toBe(scoreLexical(fields, tokenizeLexical("release notes"), rawQuery));
2123
});
24+
25+
// Two-word raw query that no fixture contains whole, so the whole-query
26+
// substring bonus never applies and only the per-token tiers are compared.
27+
const rawQuery = "release filter";
28+
const queryTokens = tokenizeLexical(rawQuery);
29+
30+
test("ranks a name substring above a parameter name above a parameter description", () => {
31+
// Every fixture matches "filter" in its blob, so the parameter tiers have
32+
// a direct match to boost.
33+
const nameSubstring = lexicalFields(
34+
"mcp__acme__releaselog",
35+
"Filter logs",
36+
NO_LEXICAL_PARAMS,
37+
);
38+
const paramName = lexicalFields("mcp__acme__records", "Filter records", {
39+
names: ["release"],
40+
blob: "",
41+
});
42+
const paramDescription = lexicalFields(
43+
"mcp__acme__records",
44+
"Filter records",
45+
{
46+
names: ["id"],
47+
blob: "Release ID or slug",
48+
},
49+
);
50+
const scores = [nameSubstring, paramName, paramDescription].map((fields) =>
51+
scoreLexical(fields, queryTokens, rawQuery),
52+
);
53+
expect(scores[0]).toBeGreaterThan(scores[1] ?? 0);
54+
expect(scores[1]).toBeGreaterThan(scores[2] ?? 0);
55+
expect(scores[2]).toBeGreaterThan(0);
56+
});
57+
58+
test("ranks a parameter name above a description substring of the same word", () => {
59+
// "release" sits only inside "prereleases", so the description offers
60+
// just a raw-substring hit.
61+
const without = lexicalFields(
62+
"mcp__acme__records",
63+
"Read prereleases",
64+
NO_LEXICAL_PARAMS,
65+
);
66+
const withParam = lexicalFields("mcp__acme__records", "Read prereleases", {
67+
names: ["release"],
68+
blob: "",
69+
});
70+
expect(scoreLexical(withParam, queryTokens, rawQuery)).toBeGreaterThan(
71+
scoreLexical(without, queryTokens, rawQuery),
72+
);
73+
});
74+
75+
test("a parameter match alone scores nothing", () => {
76+
const fields = lexicalFields("mcp__acme__records", "Read records", {
77+
names: ["release"],
78+
blob: "Release ID or slug",
79+
});
80+
expect(scoreLexical(fields, queryTokens, rawQuery)).toBe(0);
81+
});
82+
83+
test("a parameter match adds nothing to a word already matched by name or description", () => {
84+
const params = { names: ["release"], blob: "Release ID or slug" };
85+
for (const [name, blob] of [
86+
["mcp__acme__release", "Read logs"],
87+
["mcp__acme__records", "Read a release"],
88+
["mcp__acme__releaselog", "Read logs"],
89+
] as const) {
90+
expect(
91+
scoreLexical(lexicalFields(name, blob, params), queryTokens, rawQuery),
92+
).toBe(
93+
scoreLexical(
94+
lexicalFields(name, blob, NO_LEXICAL_PARAMS),
95+
queryTokens,
96+
rawQuery,
97+
),
98+
);
99+
}
100+
});
22101
});

‎src/agent/lexical-rank.ts‎

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
// One shared dependency-free lexical ranker behind tool_search,
22
// search_agents, and skill_search. Exact name-token hits weigh most, then
3-
// blob-token hits, then raw-substring matches (so "linear" finds
4-
// mcp__linear__* even though it is not a whole token there).
3+
// blob-token hits, then a raw name substring (so "linear" finds
4+
// mcp__linear__* even though it is not a whole token there), then a raw blob
5+
// substring. Parameter name and description tokens (tool_search only) weigh
6+
// less than a name substring and only boost an item that already matches by
7+
// name or blob: a filter makes the tool that carries it rank higher, but a
8+
// query that matches nothing except parameter text stays a miss, so
9+
// tool_search still waits for servers that are connecting.
510
export function tokenizeLexical(text: string): string[] {
611
return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
712
}
@@ -11,36 +16,79 @@ export interface LexicalFields {
1116
readonly blob: string;
1217
readonly nameTokens: readonly string[];
1318
readonly blobTokens: ReadonlySet<string>;
19+
readonly paramNameTokens: ReadonlySet<string>;
20+
readonly paramBlobTokens: ReadonlySet<string>;
1421
}
1522

16-
export function lexicalFields(name: string, blob: string): LexicalFields {
23+
interface LexicalParams {
24+
readonly names: readonly string[];
25+
readonly blob: string;
26+
}
27+
28+
export const NO_LEXICAL_PARAMS: LexicalParams = { names: [], blob: "" };
29+
30+
// JSON Schema keys are usually camelCase, and an undescribed flag such as
31+
// `includeArchived` carries its word only inside the key.
32+
function splitCamelCase(key: string): string {
33+
return key
34+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
35+
.replace(/([A-Z])([A-Z][a-z])/g, "$1 $2");
36+
}
37+
38+
export function lexicalFields(
39+
name: string,
40+
blob: string,
41+
params: LexicalParams,
42+
): LexicalFields {
1743
return {
1844
name,
1945
blob,
2046
nameTokens: tokenizeLexical(name),
2147
blobTokens: new Set(tokenizeLexical(blob)),
48+
paramNameTokens: new Set(
49+
tokenizeLexical(params.names.map(splitCamelCase).join(" ")),
50+
),
51+
paramBlobTokens: new Set(tokenizeLexical(params.blob)),
2252
};
2353
}
2454

55+
function directTokenScore(
56+
fields: LexicalFields,
57+
name: string,
58+
blob: string,
59+
token: string,
60+
): number {
61+
if (fields.nameTokens.includes(token)) return 3;
62+
if (fields.blobTokens.has(token)) return 1;
63+
if (name.includes(token)) return 0.75;
64+
if (blob.includes(token)) return 0.25;
65+
return 0;
66+
}
67+
68+
function paramTokenScore(fields: LexicalFields, token: string): number {
69+
if (fields.paramNameTokens.has(token)) return 0.5;
70+
if (fields.paramBlobTokens.has(token)) return 0.25;
71+
return 0;
72+
}
73+
2574
export function scoreLexical(
2675
fields: LexicalFields,
2776
queryTokens: readonly string[],
2877
rawQuery: string,
2978
): number {
3079
const name = fields.name.toLowerCase();
3180
const blob = fields.blob.toLowerCase();
32-
let total = 0;
81+
let matched = name.includes(rawQuery) ? 1 : 0;
82+
let total = matched;
3383
// A model repeats a word because its sentence needs it ("release ID or
3484
// release slug"), not to weight it; counting each copy multiplies the
3585
// bonus for every tool that matches that one word.
3686
for (const token of new Set(queryTokens)) {
37-
if (fields.nameTokens.includes(token)) total += 3;
38-
else if (fields.blobTokens.has(token)) total += 1;
39-
else if (name.includes(token)) total += 0.75;
40-
else if (blob.includes(token)) total += 0.25;
87+
const direct = directTokenScore(fields, name, blob, token);
88+
matched += direct;
89+
total += Math.max(direct, paramTokenScore(fields, token));
4190
}
42-
if (name.includes(rawQuery)) total += 1;
43-
return total;
91+
return matched > 0 ? total : 0;
4492
}
4593

4694
// One shared rank→filter→sort→slice→map cut behind tool_search,

‎src/agent/skill-search.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { type } from "arktype";
66
import type { SkillSummary } from "../extensions/skills.js";
77
import {
88
lexicalFields,
9+
NO_LEXICAL_PARAMS,
910
rankAndCut,
1011
scoreLexical,
1112
tokenizeLexical,
@@ -67,7 +68,7 @@ function scoreSkill(
6768
rawQuery: string,
6869
): number {
6970
return scoreLexical(
70-
lexicalFields(skill.name, skill.description),
71+
lexicalFields(skill.name, skill.description, NO_LEXICAL_PARAMS),
7172
queryTokens,
7273
rawQuery,
7374
);

0 commit comments

Comments
 (0)