Skip to content

Commit c24ae1a

Browse files
authored
tools: ensure virtual tool names are unique (#530)
Closes microsoft/vscode#259690
1 parent 2b92bda commit c24ae1a

File tree

4 files changed

+199
-14
lines changed

4 files changed

+199
-14
lines changed

src/extension/tools/common/virtualTools/virtualTool.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,30 @@ export const VIRTUAL_TOOL_NAME_PREFIX = 'activate_';
1010

1111
export interface IVirtualToolMetadata {
1212
toolsetKey: string;
13+
possiblePrefix?: string;
1314
groups: ISummarizedToolCategory[];
1415
preExpanded?: boolean;
1516
}
1617

1718
export class VirtualTool {
1819
public isExpanded = false;
19-
public contents: (LanguageModelToolInformation | VirtualTool)[] = [];
2020

2121
constructor(
2222
public readonly name: string,
2323
public readonly description: string,
2424
public lastUsedOnTurn: number,
2525
public readonly metadata: IVirtualToolMetadata,
26+
public contents: (LanguageModelToolInformation | VirtualTool)[] = [],
2627
) {
2728
if (!name.startsWith(VIRTUAL_TOOL_NAME_PREFIX)) {
2829
throw new Error(`Virtual tool name must start with '${VIRTUAL_TOOL_NAME_PREFIX}'`);
2930
}
3031
}
3132

33+
public cloneWithPrefix(prefix: string) {
34+
return new VirtualTool(VIRTUAL_TOOL_NAME_PREFIX + prefix + this.name.slice(VIRTUAL_TOOL_NAME_PREFIX.length), this.description, this.lastUsedOnTurn, { ...this.metadata, possiblePrefix: undefined }, this.contents);
35+
}
36+
3237
/**
3338
* Looks up a tool. Update the {@link lastUsedOnTurn} of all virtual tools
3439
* it touches.

src/extension/tools/common/virtualTools/virtualToolGrouper.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export class VirtualToolGrouper implements IToolCategorization {
6969
}));
7070

7171
this._cache.flush();
72-
root.contents = grouped.flat();
72+
root.contents = VirtualToolGrouper.deduplicateGroups(grouped.flat());
7373

7474
for (const tool of root.all()) {
7575
if (tool instanceof VirtualTool) {
@@ -85,6 +85,30 @@ export class VirtualToolGrouper implements IToolCategorization {
8585
this._reExpandToolsToHitBudget(root);
8686
}
8787

88+
public static deduplicateGroups(grouped: readonly (VirtualTool | LanguageModelToolInformation)[]) {
89+
const seen = new Map<string, VirtualTool | LanguageModelToolInformation>();
90+
91+
for (const item of grouped) {
92+
const saw = seen.get(item.name);
93+
if (!saw) {
94+
seen.set(item.name, item);
95+
continue;
96+
}
97+
98+
if (saw instanceof VirtualTool && saw.metadata.possiblePrefix) {
99+
seen.delete(saw.name);
100+
const replacement = saw.cloneWithPrefix(saw.metadata.possiblePrefix);
101+
seen.set(replacement.name, replacement);
102+
seen.set(item.name, item);
103+
} else if (item instanceof VirtualTool && item.metadata.possiblePrefix) {
104+
const next = item.cloneWithPrefix(item.metadata.possiblePrefix);
105+
seen.set(next.name, next);
106+
}
107+
}
108+
109+
return [...seen.values()];
110+
}
111+
88112
/**
89113
* Eagerly expand small groups when possible just to reduce the number of indirections.
90114
* Later we should rank this based on query/embedding similarity to the request.
@@ -182,8 +206,15 @@ export class VirtualToolGrouper implements IToolCategorization {
182206
}, { retries, durationMs: sw.elapsed() });
183207

184208
const virtualTools: (VirtualTool | LanguageModelToolInformation)[] = virts?.map(v => {
185-
const vt = new VirtualTool(VIRTUAL_TOOL_NAME_PREFIX + v.name, SUMMARY_PREFIX + v.summary + SUMMARY_SUFFIX, 0, { toolsetKey: key, groups: virts });
186-
vt.contents = v.tools;
209+
const src = tools[0].source;
210+
const possiblePrefix = src instanceof LanguageModelToolExtensionSource
211+
? (src.id.split('.').at(1) || src.id)
212+
: src?.label;
213+
const vt = new VirtualTool(VIRTUAL_TOOL_NAME_PREFIX + v.name, SUMMARY_PREFIX + v.summary + SUMMARY_SUFFIX, 0, {
214+
toolsetKey: key,
215+
groups: virts,
216+
possiblePrefix: possiblePrefix?.replaceAll(/[^a-zA-Z0-9]/g, '_').slice(0, 10) + '_'
217+
}, v.tools);
187218
return vt;
188219
}) || [];
189220

src/extension/tools/common/virtualTools/virtualToolSummarizer.tsx

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,25 @@ function processCategorizationResponse(json: { name: string; summary: string; to
5656
}
5757

5858
function validateAndCleanupCategories(categories: ISummarizedToolCategory[]): ISummarizedToolCategory[] {
59-
const seen = new Set<string>();
60-
return categories.map(category => ({
61-
...category,
62-
name: normalizeGroupName(category.name),
63-
tools: deduplicateTools(category.tools, seen),
64-
}));
59+
const byName = new Map<string, ISummarizedToolCategory>();
60+
for (const category of categories) {
61+
const name = normalizeGroupName(category.name);
62+
const existing = byName.get(name);
63+
if (!existing) {
64+
byName.set(category.name, { tools: category.tools, name, summary: category.summary });
65+
} else {
66+
if (category.summary && category.summary !== existing.summary) {
67+
existing.summary = `${existing.summary}\n\n${category.summary}`;
68+
}
69+
existing.tools = existing.tools.concat(category.tools);
70+
}
71+
}
72+
73+
for (const category of byName.values()) {
74+
category.tools = deduplicateTools(category.tools);
75+
}
76+
77+
return [...byName.values()];
6578
}
6679

6780
/**
@@ -216,7 +229,7 @@ class CategorizerSummaryPrompt extends PromptElement<BasePromptElementProps & {
216229
<SystemMessage>
217230
Context: There are many tools available for a user. However, the number of tools can be large, and it is not always practical to present all of them at once. We need to create logical groups for the user to pick from at a glance.<br />
218231
<br />
219-
The user present you with the tools available to them, and you must group them into logical categories and provide a summary of each one. The summary should include the capabilities of the tools and when they should be used. Every tool MUST be a part of EXACTLY one category.<br />
232+
The user present you with the tools available to them, and you must group them into logical categories and provide a summary of each one. The summary should include the capabilities of the tools and when they should be used. Every tool MUST be a part of EXACTLY one category. Category names in your response MUST be unique—do not reuse the same name for different categories. If two categories would share a base name, append a short, descriptive suffix to disambiguate (e.g., python_tools_testing vs python_tools_packaging).<br />
220233
</SystemMessage>
221234
<UserMessage>
222235
{this.props.tools.map(tool => <ToolInformation tool={tool} />)}<br />
@@ -232,7 +245,7 @@ class CategorizerSummaryPrompt extends PromptElement<BasePromptElementProps & {
232245
properties: {
233246
name: {
234247
type: 'string',
235-
description: 'A short name for the category. It may only contain the characters a-z, A-Z, 0-9, and underscores.',
248+
description: 'A short, unique name for the category across this response. It may only contain the characters a-z, A-Z, 0-9, and underscores. If a potential collision exists, add a short suffix to keep names unique (e.g., _testing, _packaging).',
236249
example: 'foo_language_tools'
237250
},
238251
tools: {
@@ -273,7 +286,7 @@ class ExistingGroupCategorizerPrompt extends PromptElement<BasePromptElementProp
273286
<br />
274287
The user will provide you with the existing categories and their current tools, as well as the new tools that need to be categorized. You must assign each new tool to either an existing category (if it fits well) or create new categories as needed. You should also return all existing tools in their current categories unless there's a compelling reason to reorganize them.<br />
275288
<br />
276-
Every tool (both existing and new) MUST be part of EXACTLY one category in your response.<br />
289+
Every tool (both existing and new) MUST be part of EXACTLY one category in your response. Category names MUST be unique within the response. If a new category would conflict with an existing category name, choose a distinct, disambiguating name.<br />
277290
</SystemMessage>
278291
<UserMessage>
279292
**Existing Categories:**<br />
@@ -300,7 +313,7 @@ class ExistingGroupCategorizerPrompt extends PromptElement<BasePromptElementProp
300313
properties: {
301314
name: {
302315
type: 'string',
303-
description: 'A short name for the category. It may only contain the characters a-z, A-Z, 0-9, and underscores.',
316+
description: 'A short, unique name for the category across this response. It may only contain the characters a-z, A-Z, 0-9, and underscores. Do not reuse names; add a short suffix if needed to avoid collisions.',
304317
example: 'foo_language_tools'
305318
},
306319
tools: {

src/extension/tools/test/node/virtualTools/virtualToolGrouper.spec.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,83 @@ describe('Virtual Tools - Grouper', () => {
8080
root.isExpanded = true;
8181
});
8282

83+
describe('_deduplicateGroups', () => {
84+
function vt(name: string, possiblePrefix?: string): VirtualTool {
85+
return new VirtualTool(
86+
name,
87+
`VT ${name}`,
88+
0,
89+
{ toolsetKey: 'k', groups: [], possiblePrefix },
90+
[]
91+
);
92+
}
93+
94+
it('deduplicates VirtualTool against LM tool by prefixing existing VT', () => {
95+
const dupName = `${VIRTUAL_TOOL_NAME_PREFIX}foo`;
96+
const items = [
97+
vt(dupName, 'ext_'),
98+
makeTool(dupName),
99+
];
100+
101+
const result = VirtualToolGrouper.deduplicateGroups(items) as Array<VirtualTool | LanguageModelToolInformation>;
102+
103+
// Expect both the LM tool and the prefixed VT to exist, and no unprefixed VT
104+
const names = result.map(i => i.name);
105+
expect(names).toContain(dupName);
106+
expect(names).toContain(`activate_ext_${dupName.slice(VIRTUAL_TOOL_NAME_PREFIX.length)}`);
107+
expect(result.find(i => i instanceof VirtualTool && i.name === dupName)).toBeUndefined();
108+
});
109+
110+
it('deduplicates LM tool against VirtualTool by prefixing new VT', () => {
111+
const dupName = `${VIRTUAL_TOOL_NAME_PREFIX}bar`;
112+
const items = [
113+
makeTool(dupName),
114+
vt(dupName, 'mcp_'),
115+
];
116+
117+
const result = VirtualToolGrouper.deduplicateGroups(items) as Array<VirtualTool | LanguageModelToolInformation>;
118+
const names = result.map(i => i.name);
119+
expect(names).toContain(dupName); // LM tool remains under original name
120+
expect(names).toContain(`activate_mcp_${dupName.slice(VIRTUAL_TOOL_NAME_PREFIX.length)}`); // VT is cloned with prefix
121+
});
122+
123+
it('handles VT vs VT duplicate by prefixing the first and keeping the second', () => {
124+
const dupName = `${VIRTUAL_TOOL_NAME_PREFIX}baz`;
125+
const first = vt(dupName, 'ext_');
126+
const second = vt(dupName, 'mcp_');
127+
const result = VirtualToolGrouper.deduplicateGroups([first, second]) as Array<VirtualTool | LanguageModelToolInformation>;
128+
129+
const vtPrefixed = result.find(i => i instanceof VirtualTool && i.name === `activate_ext_${dupName.slice(VIRTUAL_TOOL_NAME_PREFIX.length)}`) as VirtualTool | undefined;
130+
const vtUnprefixed = result.find(i => i.name === dupName) as VirtualTool | undefined;
131+
132+
expect(vtPrefixed).toBeDefined();
133+
// Second VT should remain at the original (unprefixed) name
134+
expect(vtUnprefixed).toBeInstanceOf(VirtualTool);
135+
});
136+
137+
it('drops duplicate when no possiblePrefix is available on VT', () => {
138+
const dupName = `${VIRTUAL_TOOL_NAME_PREFIX}qux`;
139+
const items = [
140+
vt(dupName), // no possiblePrefix
141+
makeTool(dupName),
142+
];
143+
144+
const result = VirtualToolGrouper.deduplicateGroups(items) as Array<VirtualTool | LanguageModelToolInformation>;
145+
// Only the first VT remains
146+
expect(result).toHaveLength(1);
147+
expect(result[0]).toBeInstanceOf(VirtualTool);
148+
expect(result[0].name).toBe(dupName);
149+
});
150+
151+
it('keeps only the first LM tool on LM vs LM duplicate', () => {
152+
const dupName = `${VIRTUAL_TOOL_NAME_PREFIX}dup`;
153+
const items = [makeTool(dupName), makeTool(dupName)];
154+
const result = VirtualToolGrouper.deduplicateGroups(items) as Array<VirtualTool | LanguageModelToolInformation>;
155+
expect(result).toHaveLength(1);
156+
expect(result[0].name).toBe(dupName);
157+
});
158+
});
159+
83160
afterEach(() => {
84161
accessor.dispose();
85162
});
@@ -467,4 +544,63 @@ describe('Virtual Tools - Grouper', () => {
467544
expect(root.contents).toEqual(tools);
468545
});
469546
});
547+
548+
/**
549+
* Tests for the deduplication logic that ensures unique names by prefixing
550+
* virtual tools when necessary.
551+
*/
552+
describe('deduplicateGroups', () => {
553+
it('keeps unique items unchanged', () => {
554+
const items = [
555+
makeTool('a'),
556+
new VirtualTool(`${VIRTUAL_TOOL_NAME_PREFIX}groupA`, 'desc', 0, { toolsetKey: 'k', groups: [], possiblePrefix: 'ext_' }),
557+
makeTool('b'),
558+
];
559+
const out = VirtualToolGrouper.deduplicateGroups(items);
560+
expect(out.map(i => i.name)).toEqual(['a', `${VIRTUAL_TOOL_NAME_PREFIX}groupA`, 'b']);
561+
});
562+
563+
it('prefixes first seen virtual tool if a later collision occurs with a real tool', () => {
564+
const v = new VirtualTool(`${VIRTUAL_TOOL_NAME_PREFIX}conflict`, 'desc', 0, { toolsetKey: 'k', groups: [], possiblePrefix: 'ext_' });
565+
const real: LanguageModelToolInformation = makeTool(`${VIRTUAL_TOOL_NAME_PREFIX}conflict`);
566+
const out = VirtualToolGrouper.deduplicateGroups([v, real]);
567+
expect(out.map(i => i.name).sort()).toEqual(['activate_conflict', 'activate_ext_conflict'].sort());
568+
});
569+
570+
it('prefixes newly seen virtual tool when collision occurs with an existing real tool', () => {
571+
const real: LanguageModelToolInformation = makeTool(`${VIRTUAL_TOOL_NAME_PREFIX}c`);
572+
const v = new VirtualTool(`${VIRTUAL_TOOL_NAME_PREFIX}c`, 'desc', 0, { toolsetKey: 'k', groups: [], possiblePrefix: 'mcp_' });
573+
const out = VirtualToolGrouper.deduplicateGroups([real, v]);
574+
expect(out.map(i => i.name).sort()).toEqual(['activate_c', 'activate_mcp_c'].sort());
575+
});
576+
577+
it('replaces earlier virtual tool with prefixed clone when colliding with later virtual tool', () => {
578+
const v1 = new VirtualTool(`${VIRTUAL_TOOL_NAME_PREFIX}x`, 'd1', 0, { toolsetKey: 'k', groups: [], possiblePrefix: 'ext_' });
579+
const v2 = new VirtualTool(`${VIRTUAL_TOOL_NAME_PREFIX}x`, 'd2', 0, { toolsetKey: 'k', groups: [], possiblePrefix: 'mcp_' });
580+
const out = VirtualToolGrouper.deduplicateGroups([v1, v2]);
581+
// first is replaced with ext_ prefix, second remains as-is (still original name)
582+
expect(out.map(i => i.name).sort()).toEqual(['activate_ext_x', 'activate_x'].sort());
583+
});
584+
585+
it('no prefixing when virtual has no possiblePrefix', () => {
586+
const v1 = new VirtualTool(`${VIRTUAL_TOOL_NAME_PREFIX}dup`, 'd1', 0, { toolsetKey: 'k', groups: [] });
587+
const v2 = new VirtualTool(`${VIRTUAL_TOOL_NAME_PREFIX}dup`, 'd2', 0, { toolsetKey: 'k', groups: [], possiblePrefix: 'ext_' });
588+
const out = VirtualToolGrouper.deduplicateGroups([v1, v2]);
589+
// Since first has no prefix, second with prefix should be applied
590+
expect(out.map(i => i.name).sort()).toEqual(['activate_dup', 'activate_ext_dup'].sort());
591+
});
592+
593+
it('handles multiple collisions consistently', () => {
594+
const items: (VirtualTool | LanguageModelToolInformation)[] = [
595+
new VirtualTool(`${VIRTUAL_TOOL_NAME_PREFIX}n`, 'd', 0, { toolsetKey: 'k', groups: [], possiblePrefix: 'e_' }),
596+
makeTool(`${VIRTUAL_TOOL_NAME_PREFIX}n`),
597+
new VirtualTool(`${VIRTUAL_TOOL_NAME_PREFIX}n`, 'd2', 0, { toolsetKey: 'k', groups: [], possiblePrefix: 'm_' }),
598+
makeTool(`${VIRTUAL_TOOL_NAME_PREFIX}p`),
599+
new VirtualTool(`${VIRTUAL_TOOL_NAME_PREFIX}p`, 'd3', 0, { toolsetKey: 'k', groups: [], possiblePrefix: 'x_' }),
600+
];
601+
const out = VirtualToolGrouper.deduplicateGroups(items);
602+
const names = out.map(i => i.name).sort();
603+
expect(names).toEqual(['activate_n', 'activate_e_n', 'activate_m_n', 'activate_p', 'activate_x_p'].sort());
604+
});
605+
});
470606
});

0 commit comments

Comments
 (0)