Skip to content

Commit 173e4e1

Browse files
Share lexical search ranker across tool, skill, and agent search
1 parent dacd6a3 commit 173e4e1

4 files changed

Lines changed: 111 additions & 90 deletions

File tree

src/agent/agent-search.ts

Lines changed: 13 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,9 @@ import type { AgentTool } from "@intx/agent";
33
import type { ToolDefinition } from "@intx/types/runtime";
44
import { type } from "arktype";
55
import { scrubSecretShapedContent } from "../plugins/tool-result-secret-scrub.js";
6+
import { rankLexicalMatches, tokenize } from "./lexical-search.js";
67
import type { AgentProfile } from "./profiles.js";
78

8-
function tokenize(text: string): string[] {
9-
return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
10-
}
11-
129
function profileSearchText(profile: AgentProfile): string {
1310
const parts = [profile.id, profile.description ?? "", profile.systemPromptRole ?? ""];
1411
return parts.join(" ");
@@ -18,36 +15,22 @@ export interface AgentIndex {
1815
search(query: string, limit?: number): AgentProfile[];
1916
}
2017

21-
// Lexical ranker over id, description, and role text — same spirit as tool_search.
18+
// Lexical ranker over id, description, and role text — same weights as
19+
// tool_search / skill_search, shared via lexical-search.ts.
2220
export function createAgentIndex(getProfiles: () => readonly AgentProfile[]): AgentIndex {
23-
const score = (profile: AgentProfile, queryTokens: string[], rawQuery: string): number => {
24-
const idTokens = tokenize(profile.id);
25-
const blob = profileSearchText(profile).toLowerCase();
26-
const blobTokens = new Set(tokenize(blob));
27-
let total = 0;
28-
for (const token of queryTokens) {
29-
if (idTokens.includes(token)) total += 3;
30-
else if (blobTokens.has(token)) total += 1;
31-
else if (profile.id.toLowerCase().includes(token)) total += 0.75;
32-
else if (blob.includes(token)) total += 0.25;
33-
}
34-
if (profile.id.toLowerCase().includes(rawQuery)) total += 1;
35-
if ((profile.description ?? "").toLowerCase().includes(rawQuery)) total += 0.5;
36-
return total;
37-
};
38-
3921
return {
4022
search(query: string, limit = 12): AgentProfile[] {
41-
const rawQuery = query.toLowerCase().trim();
42-
const queryTokens = tokenize(query);
4323
const profiles = getProfiles();
44-
if (queryTokens.length === 0) return profiles.slice(0, limit);
45-
return profiles
46-
.map((p) => ({ profile: p, score: score(p, queryTokens, rawQuery) }))
47-
.filter((entry) => entry.score > 0)
48-
.sort((a, b) => b.score - a.score)
49-
.slice(0, limit)
50-
.map((entry) => entry.profile);
24+
if (tokenize(query).length === 0) return profiles.slice(0, limit);
25+
return rankLexicalMatches(
26+
profiles,
27+
(profile) => ({ name: profile.id, text: profileSearchText(profile) }),
28+
query,
29+
limit,
30+
// A raw-query hit in the description outranks a role-text-only hit.
31+
(profile, rawQuery) =>
32+
(profile.description ?? "").toLowerCase().includes(rawQuery) ? 0.5 : 0,
33+
);
5134
},
5235
};
5336
}

src/agent/lexical-search.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Shared lexical ranker for the agent-side search tools (tool_search,
2+
// skill_search, search_agents). Each used to carry its own copy of the
3+
// tokenizer and scoring weights; keeping them here means a ranking change
4+
// (weight tuning, tokenizer tweaks) lands in one place instead of drifting
5+
// across three copies.
6+
7+
/** Lowercase alphanumeric tokens; any other character splits tokens. */
8+
export function tokenize(text: string): string[] {
9+
return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
10+
}
11+
12+
/** The two scored surfaces of a searchable document. */
13+
export interface LexicalSearchFields {
14+
/** Short identifier — exact token hits weigh most (tool name, skill name, agent id). */
15+
name: string;
16+
/** Body text — token hits weigh less (description, role text). */
17+
text: string;
18+
}
19+
20+
/**
21+
* Score one document against a query. Exact name-token hits weigh most (3),
22+
* then text-token hits (1), then raw-substring matches (0.75 in the name,
23+
* 0.25 in the text — so "linear" finds mcp__linear__* even though it is not
24+
* a whole token there), plus a raw-query hit in the name (+1). `extra` lets a
25+
* caller add a document-specific term without forking the weights.
26+
*/
27+
export function scoreLexicalMatch(
28+
fields: LexicalSearchFields,
29+
queryTokens: readonly string[],
30+
rawQuery: string,
31+
extra = 0,
32+
): number {
33+
const nameTokens = tokenize(fields.name);
34+
const textTokens = new Set(tokenize(fields.text));
35+
const nameLower = fields.name.toLowerCase();
36+
const textLower = fields.text.toLowerCase();
37+
let total = 0;
38+
for (const token of queryTokens) {
39+
if (nameTokens.includes(token)) total += 3;
40+
else if (textTokens.has(token)) total += 1;
41+
else if (nameLower.includes(token)) total += 0.75;
42+
else if (textLower.includes(token)) total += 0.25;
43+
}
44+
if (nameLower.includes(rawQuery)) total += 1;
45+
return total + extra;
46+
}
47+
48+
/**
49+
* Rank `items` against a query: score each document, drop zero-score hits,
50+
* order by descending score (stable — ties keep input order), and cap at
51+
* `limit`. Returns [] for an empty or whitespace-only query (no tokens to
52+
* match), so callers that treat an empty query as "return everything" (agent
53+
* search) keep that branch on their side. `extra` lets a caller add a
54+
* per-item scoring term without forking the weights.
55+
*/
56+
export function rankLexicalMatches<T>(
57+
items: readonly T[],
58+
fieldsFor: (item: T) => LexicalSearchFields,
59+
query: string,
60+
limit: number,
61+
extra?: (item: T, rawQuery: string) => number,
62+
): T[] {
63+
const rawQuery = query.toLowerCase().trim();
64+
const queryTokens = tokenize(query);
65+
if (queryTokens.length === 0) return [];
66+
return items
67+
.map((item) => ({
68+
item,
69+
score: scoreLexicalMatch(
70+
fieldsFor(item),
71+
queryTokens,
72+
rawQuery,
73+
extra?.(item, rawQuery) ?? 0,
74+
),
75+
}))
76+
.filter((entry) => entry.score > 0)
77+
.sort((a, b) => b.score - a.score)
78+
.slice(0, limit)
79+
.map((entry) => entry.item);
80+
}

src/agent/skill-search.ts

Lines changed: 7 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ToolDefinition } from "@intx/types/runtime";
44
import { type } from "arktype";
55

66
import type { SkillSummary } from "../extensions/skills.js";
7+
import { rankLexicalMatches } from "./lexical-search.js";
78

89
// Catalog lookup for skills. Names live in the system prompt; this tool returns
910
// matching name + description so the model can choose. Bodies load via use_skill.
@@ -33,10 +34,6 @@ export interface CreateSkillSearchToolArgs {
3334
allowedNames?: readonly string[];
3435
}
3536

36-
function tokenize(text: string): string[] {
37-
return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
38-
}
39-
4037
function visibleSkills(
4138
skills: readonly SkillSummary[],
4239
allowedNames: readonly string[] | undefined,
@@ -46,20 +43,6 @@ function visibleSkills(
4643
return skills.filter((skill) => allowed.has(skill.name));
4744
}
4845

49-
function scoreSkill(skill: SkillSummary, queryTokens: string[], rawQuery: string): number {
50-
const nameTokens = tokenize(skill.name);
51-
const descTokens = new Set(tokenize(skill.description));
52-
let total = 0;
53-
for (const token of queryTokens) {
54-
if (nameTokens.includes(token)) total += 3;
55-
else if (descTokens.has(token)) total += 1;
56-
else if (skill.name.toLowerCase().includes(token)) total += 0.75;
57-
else if (skill.description.toLowerCase().includes(token)) total += 0.25;
58-
}
59-
if (skill.name.toLowerCase().includes(rawQuery)) total += 1;
60-
return total;
61-
}
62-
6346
const SkillSearchArgs = type({ query: "string" });
6447

6548
const DEFAULT_LIMIT = 8;
@@ -75,17 +58,12 @@ export function createSkillSearchTool(args: CreateSkillSearchToolArgs): AgentToo
7558
}
7659
const query = parsed.query.trim();
7760
if (query.length === 0) return "Error: skill_search requires a non-empty query.";
78-
const rawQuery = query.toLowerCase();
79-
const queryTokens = tokenize(query);
80-
if (queryTokens.length === 0) {
81-
return `No skills matched "${query}". Try different keywords describing the capability.`;
82-
}
83-
const matches = catalog
84-
.map((skill) => ({ skill, score: scoreSkill(skill, queryTokens, rawQuery) }))
85-
.filter((entry) => entry.score > 0)
86-
.sort((a, b) => b.score - a.score)
87-
.slice(0, DEFAULT_LIMIT)
88-
.map((entry) => entry.skill);
61+
const matches = rankLexicalMatches(
62+
catalog,
63+
(skill) => ({ name: skill.name, text: skill.description }),
64+
query,
65+
DEFAULT_LIMIT,
66+
);
8967
if (matches.length === 0) {
9068
return `No skills matched "${query}". Try different keywords describing the capability.`;
9169
}

src/agent/tool-search.ts

Lines changed: 11 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { type } from "arktype";
55

66
import type { SessionMode } from "../config/session-mode.js";
77
import { sessionModeEnablesSubAgents } from "../config/session-mode.js";
8+
import { rankLexicalMatches } from "./lexical-search.js";
89

910
// Tools whose full schema is always advertised to the model. Everything else is
1011
// registered and dispatchable but discovered on demand via tool_search, keeping
@@ -194,43 +195,22 @@ export interface ToolIndex {
194195
search(query: string, limit?: number): string[];
195196
}
196197

197-
function tokenize(text: string): string[] {
198-
return text.toLowerCase().match(/[a-z0-9]+/g) ?? [];
199-
}
200-
201-
// A dependency-free lexical ranker over each tool's name + description. Exact name
202-
// token hits weigh most, then description token hits, then raw-substring matches
203-
// (so "linear" finds mcp__linear__* even though it is not a whole token there).
198+
// Lexical ranker over each tool's name + description — weights shared with
199+
// skill_search and search_agents (see lexical-search.ts). Exact name token hits
200+
// weigh most, then description token hits, then raw-substring matches (so
201+
// "linear" finds mcp__linear__* even though it is not a whole token there).
204202
export function createToolIndex(
205203
getDefs: () => readonly ToolDefinition[],
206204
advertisedNames: readonly string[] = ADVERTISED_TOOL_NAMES,
207205
): ToolIndex {
208-
const score = (def: ToolDefinition, queryTokens: string[], rawQuery: string): number => {
209-
const nameTokens = tokenize(def.name);
210-
const descTokens = new Set(tokenize(def.description ?? ""));
211-
let total = 0;
212-
for (const token of queryTokens) {
213-
if (nameTokens.includes(token)) total += 3;
214-
else if (descTokens.has(token)) total += 1;
215-
else if (def.name.toLowerCase().includes(token)) total += 0.75;
216-
else if ((def.description ?? "").toLowerCase().includes(token)) total += 0.25;
217-
}
218-
if (def.name.toLowerCase().includes(rawQuery)) total += 1;
219-
return total;
220-
};
221-
222206
return {
223207
search(query: string, limit = 8): string[] {
224-
const rawQuery = query.toLowerCase().trim();
225-
const queryTokens = tokenize(query);
226-
if (queryTokens.length === 0) return [];
227-
return getDefs()
228-
.filter((def) => !advertisedNames.includes(def.name))
229-
.map((def) => ({ name: def.name, score: score(def, queryTokens, rawQuery) }))
230-
.filter((entry) => entry.score > 0)
231-
.sort((a, b) => b.score - a.score)
232-
.slice(0, limit)
233-
.map((entry) => entry.name);
208+
return rankLexicalMatches(
209+
getDefs().filter((def) => !advertisedNames.includes(def.name)),
210+
(def) => ({ name: def.name, text: def.description ?? "" }),
211+
query,
212+
limit,
213+
).map((def) => def.name);
234214
},
235215
};
236216
}

0 commit comments

Comments
 (0)