Agent Skills cleanup (#283934)

This commit is contained in:
Paul
2025-12-17 13:56:51 -08:00
committed by GitHub
parent 1d4be24a05
commit 82fb5e1465
9 changed files with 160 additions and 66 deletions

View File

@@ -704,10 +704,10 @@ configurationRegistry.registerConfiguration({
disallowConfigurationDefault: true,
tags: ['experimental', 'prompts', 'reusable prompts', 'prompt snippets', 'instructions']
},
[PromptsConfig.USE_CLAUDE_SKILLS]: {
[PromptsConfig.USE_AGENT_SKILLS]: {
type: 'boolean',
title: nls.localize('chat.useClaudeSkills.title', "Use Claude skills",),
markdownDescription: nls.localize('chat.useClaudeSkills.description', "Controls whether Claude skills found in the workspace and user home directories under `.claude/skills` are listed in all chat requests. The language model can load these skills on-demand if the `read` tool is available.",),
title: nls.localize('chat.useAgentSkills.title', "Use Agent skills",),
markdownDescription: nls.localize('chat.useAgentSkills.description', "Controls whether Agent skills found at `.github/skills`, `.claude/skills`, `~/.claude/skills` are listed in all chat requests. The language model can load these skills on-demand if the `read` tool is available.",),
default: false,
restricted: true,
disallowConfigurationDefault: true,
@@ -862,6 +862,13 @@ Registry.as<IConfigurationMigrationRegistry>(Extensions.ConfigurationMigration).
['chat.detectParticipant.enabled', { value: value !== false }]
])
},
{
key: 'chat.useClaudeSkills',
migrateFn: (value, _accessor) => ([
['chat.useClaudeSkills', { value: undefined }],
['chat.useAgentSkills', { value }]
])
},
{
key: mcpDiscoverySection,
migrateFn: (value: unknown) => {

View File

@@ -304,13 +304,13 @@ export class ComputeAutomaticInstructions {
entries.push('</instructions>', '', ''); // add trailing newline
}
const claudeSkills = await this._promptsService.findClaudeSkills(token);
if (claudeSkills && claudeSkills.length > 0) {
const agentSkills = await this._promptsService.findAgentSkills(token);
if (agentSkills && agentSkills.length > 0) {
entries.push('<skills>');
entries.push('Here is a list of skills that contain domain specific knowledge on a variety of topics.');
entries.push('Each skill comes with a description of the topic and a file path that contains the detailed instructions.');
entries.push(`When a user asks you to perform a task that falls within the domain of a skill, use the ${readTool.variable} tool to acquire the full instructions from the file URI.`);
for (const skill of claudeSkills) {
for (const skill of agentSkills) {
entries.push('<skill>');
entries.push(`<name>${skill.name}</name>`);
if (skill.description) {

View File

@@ -79,9 +79,9 @@ export namespace PromptsConfig {
export const USE_NESTED_AGENT_MD = 'chat.useNestedAgentsMdFiles';
/**
* Configuration key for claude skills usage.
* Configuration key for agent skills usage.
*/
export const USE_CLAUDE_SKILLS = 'chat.useClaudeSkills';
export const USE_AGENT_SKILLS = 'chat.useAgentSkills';
/**
* Get value of the `reusable prompt locations` configuration setting.

View File

@@ -53,6 +53,21 @@ export const LEGACY_MODE_DEFAULT_SOURCE_FOLDER = '.github/chatmodes';
*/
export const AGENTS_SOURCE_FOLDER = '.github/agents';
/**
* Default agent skills workspace source folders.
*/
export const DEFAULT_AGENT_SKILLS_WORKSPACE_FOLDERS = [
'.github/skills',
'.claude/skills'
] as const;
/**
* Default agent skills user home source folders.
*/
export const DEFAULT_AGENT_SKILLS_USER_HOME_FOLDERS = [
'.claude/skills'
] as const;
/**
* Helper function to check if a file is directly in the .github/agents/ folder (not in subfolders).
*/

View File

@@ -198,7 +198,7 @@ export interface IChatPromptSlashCommand {
readonly parsedPromptFile: ParsedPromptFile;
}
export interface IClaudeSkill {
export interface IAgentSkill {
readonly uri: URI;
readonly type: 'personal' | 'project';
readonly name: string;
@@ -328,7 +328,7 @@ export interface IPromptsService extends IDisposable {
}): IDisposable;
/**
* Gets list of claude skills files.
* Gets list of agent skills files.
*/
findClaudeSkills(token: CancellationToken): Promise<IClaudeSkill[] | undefined>;
findAgentSkills(token: CancellationToken): Promise<IAgentSkill[] | undefined>;
}

View File

@@ -26,11 +26,12 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../../../
import { IUserDataProfileService } from '../../../../../services/userDataProfile/common/userDataProfile.js';
import { IVariableReference } from '../../chatModes.js';
import { PromptsConfig } from '../config/config.js';
import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js';
import { getCleanPromptName } from '../config/promptFileLocations.js';
import { PROMPT_LANGUAGE_ID, PromptsType, getPromptsTypeForLanguageId } from '../promptTypes.js';
import { PromptFilesLocator } from '../utils/promptFilesLocator.js';
import { PromptFileParser, ParsedPromptFile, PromptHeaderAttributes } from '../promptFileParser.js';
import { IAgentInstructions, IAgentSource, IChatPromptSlashCommand, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPromptPath, IPromptsService, IClaudeSkill, IUserPromptPath, PromptsStorage, ICustomAgentQueryOptions, IExternalCustomAgent, ExtensionAgentSourceType, CUSTOM_AGENTS_PROVIDER_ACTIVATION_EVENT } from './promptsService.js';
import { IAgentInstructions, IAgentSource, IChatPromptSlashCommand, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPromptPath, IPromptsService, IAgentSkill, IUserPromptPath, PromptsStorage, ICustomAgentQueryOptions, IExternalCustomAgent, ExtensionAgentSourceType, CUSTOM_AGENTS_PROVIDER_ACTIVATION_EVENT } from './promptsService.js';
import { Delayer } from '../../../../../../base/common/async.js';
import { Schemas } from '../../../../../../base/common/network.js';
@@ -93,7 +94,8 @@ export class PromptsService extends Disposable implements IPromptsService {
@IFileService private readonly fileService: IFileService,
@IFilesConfigurationService private readonly filesConfigService: IFilesConfigurationService,
@IStorageService private readonly storageService: IStorageService,
@IExtensionService private readonly extensionService: IExtensionService
@IExtensionService private readonly extensionService: IExtensionService,
@IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService
) {
super();
@@ -575,29 +577,31 @@ export class PromptsService extends Disposable implements IPromptsService {
}
}
// Claude skills
// Agent skills
public async findClaudeSkills(token: CancellationToken): Promise<IClaudeSkill[] | undefined> {
const useClaudeSkills = this.configurationService.getValue(PromptsConfig.USE_CLAUDE_SKILLS);
if (useClaudeSkills) {
const result: IClaudeSkill[] = [];
public async findAgentSkills(token: CancellationToken): Promise<IAgentSkill[] | undefined> {
const useAgentSkills = this.configurationService.getValue(PromptsConfig.USE_AGENT_SKILLS);
const defaultAccount = await this.defaultAccountService.getDefaultAccount();
const previewFeaturesEnabled = defaultAccount?.chat_preview_features_enabled ?? true;
if (useAgentSkills && previewFeaturesEnabled) {
const result: IAgentSkill[] = [];
const process = async (uri: URI, type: 'personal' | 'project'): Promise<void> => {
try {
const parsedFile = await this.parseNew(uri, token);
const name = parsedFile.header?.name;
if (name) {
result.push({ uri, type, name, description: parsedFile.header?.description } satisfies IClaudeSkill);
result.push({ uri, type, name, description: parsedFile.header?.description } satisfies IAgentSkill);
} else {
this.logger.error(`[findClaudeSkills] Claude skill file missing name attribute: ${uri}`);
this.logger.error(`[findAgentSkills] Agent skill file missing name attribute: ${uri}`);
}
} catch (e) {
this.logger.error(`[findClaudeSkills] Failed to parse Claude skill file: ${uri}`, e instanceof Error ? e.message : String(e));
this.logger.error(`[findAgentSkills] Failed to parse Agent skill file: ${uri}`, e instanceof Error ? e.message : String(e));
}
};
const workspaceSkills = await this.fileLocator.findClaudeSkillsInWorkspace(token);
const workspaceSkills = await this.fileLocator.findAgentSkillsInWorkspace(token);
await Promise.all(workspaceSkills.map(uri => process(uri, 'project')));
const userSkills = await this.fileLocator.findClaudeSkillsInUserHome(token);
const userSkills = await this.fileLocator.findAgentSkillsInUserHome(token);
await Promise.all(userSkills.map(uri => process(uri, 'personal')));
return result;
}

View File

@@ -11,7 +11,7 @@ import { getPromptFileLocationsConfigKey, PromptsConfig } from '../config/config
import { basename, dirname, isEqualOrParent, joinPath } from '../../../../../../base/common/resources.js';
import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js';
import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
import { COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, AGENTS_SOURCE_FOLDER, getPromptFileExtension, getPromptFileType, LEGACY_MODE_FILE_EXTENSION, getCleanPromptName, AGENT_FILE_EXTENSION } from '../config/promptFileLocations.js';
import { COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, AGENTS_SOURCE_FOLDER, getPromptFileExtension, getPromptFileType, LEGACY_MODE_FILE_EXTENSION, getCleanPromptName, AGENT_FILE_EXTENSION, DEFAULT_AGENT_SKILLS_WORKSPACE_FOLDERS, DEFAULT_AGENT_SKILLS_USER_HOME_FOLDERS } from '../config/promptFileLocations.js';
import { PromptsType } from '../promptTypes.js';
import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js';
import { Schemas } from '../../../../../../base/common/network.js';
@@ -366,10 +366,10 @@ export class PromptFilesLocator {
return undefined;
}
private async findClaudeSkillsInFolder(uri: URI, token: CancellationToken): Promise<URI[]> {
private async findAgentSkillsInFolder(uri: URI, relativePath: string, token: CancellationToken): Promise<URI[]> {
const result = [];
try {
const stat = await this.fileService.resolve(joinPath(uri, '.claude/skills'));
const stat = await this.fileService.resolve(joinPath(uri, relativePath));
if (token.isCancellationRequested) {
return [];
}
@@ -392,22 +392,33 @@ export class PromptFilesLocator {
}
/**
* Searches for skills in `.claude/skills/` directories in the workspace.
* Searches for skills in all default directories in the workspace.
* Each skill is stored in its own subdirectory with a SKILL.md file.
*/
public async findClaudeSkillsInWorkspace(token: CancellationToken): Promise<URI[]> {
public async findAgentSkillsInWorkspace(token: CancellationToken): Promise<URI[]> {
const workspace = this.workspaceService.getWorkspace();
const results = await Promise.all(workspace.folders.map(f => this.findClaudeSkillsInFolder(f.uri, token)));
return results.flat();
const allResults: URI[] = [];
for (const folder of workspace.folders) {
for (const skillsFolder of DEFAULT_AGENT_SKILLS_WORKSPACE_FOLDERS) {
const results = await this.findAgentSkillsInFolder(folder.uri, skillsFolder, token);
allResults.push(...results);
}
}
return allResults;
}
/**
* Searches for skills in `.claude/skills/` directories in the home folder.
* Searches for skills in all default directories in the home folder.
* Each skill is stored in its own subdirectory with a SKILL.md file.
*/
public async findClaudeSkillsInUserHome(token: CancellationToken): Promise<URI[]> {
public async findAgentSkillsInUserHome(token: CancellationToken): Promise<URI[]> {
const userHome = await this.pathService.userHome();
return this.findClaudeSkillsInFolder(userHome, token);
const allResults: URI[] = [];
for (const skillsFolder of DEFAULT_AGENT_SKILLS_USER_HOME_FOLDERS) {
const results = await this.findAgentSkillsInFolder(userHome, skillsFolder, token);
allResults.push(...results);
}
return allResults;
}
}

View File

@@ -11,7 +11,7 @@ import { ITextModel } from '../../../../../editor/common/model.js';
import { IExtensionDescription } from '../../../../../platform/extensions/common/extensions.js';
import { PromptsType } from '../../common/promptSyntax/promptTypes.js';
import { ParsedPromptFile } from '../../common/promptSyntax/promptFileParser.js';
import { IClaudeSkill, ICustomAgent, ICustomAgentQueryOptions, IExternalCustomAgent, IPromptPath, IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js';
import { IAgentSkill, ICustomAgent, ICustomAgentQueryOptions, IExternalCustomAgent, IPromptPath, IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js';
import { ResourceSet } from '../../../../../base/common/map.js';
export class MockPromptsService implements IPromptsService {
@@ -61,6 +61,6 @@ export class MockPromptsService implements IPromptsService {
getDisabledPromptFiles(type: PromptsType): ResourceSet { throw new Error('Method not implemented.'); }
setDisabledPromptFiles(type: PromptsType, uris: ResourceSet): void { throw new Error('Method not implemented.'); }
registerCustomAgentsProvider(extension: IExtensionDescription, provider: { provideCustomAgents: (options: ICustomAgentQueryOptions, token: CancellationToken) => Promise<IExternalCustomAgent[] | undefined> }): IDisposable { throw new Error('Method not implemented.'); }
findClaudeSkills(token: CancellationToken): Promise<IClaudeSkill[] | undefined> { throw new Error('Method not implemented.'); }
findAgentSkills(token: CancellationToken): Promise<IAgentSkill[] | undefined> { throw new Error('Method not implemented.'); }
dispose(): void { }
}

View File

@@ -43,6 +43,8 @@ import { InMemoryStorageService, IStorageService } from '../../../../../../../pl
import { IPathService } from '../../../../../../services/path/common/pathService.js';
import { ISearchService } from '../../../../../../services/search/common/search.js';
import { IExtensionService } from '../../../../../../services/extensions/common/extensions.js';
import { IDefaultAccountService } from '../../../../../../../platform/defaultAccount/common/defaultAccount.js';
import { IDefaultAccount } from '../../../../../../../base/common/defaultAccount.js';
suite('PromptsService', () => {
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
@@ -78,6 +80,10 @@ suite('PromptsService', () => {
activateByEvent: () => Promise.resolve()
});
instaService.stub(IDefaultAccountService, {
getDefaultAccount: () => Promise.resolve({ chat_preview_features_enabled: true } as IDefaultAccount)
});
fileService = disposables.add(instaService.createInstance(FileService));
instaService.stub(IFileService, fileService);
@@ -1255,50 +1261,96 @@ suite('PromptsService', () => {
});
});
suite('findClaudeSkills', () => {
suite('findAgentSkills', () => {
teardown(() => {
sinon.restore();
});
test('should return undefined when USE_CLAUDE_SKILLS is disabled', async () => {
testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_SKILLS, false);
test('should return undefined when USE_AGENT_SKILLS is disabled', async () => {
testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, false);
const result = await service.findClaudeSkills(CancellationToken.None);
const result = await service.findAgentSkills(CancellationToken.None);
assert.strictEqual(result, undefined);
});
test('should find Claude skills in workspace and user home', async () => {
testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_SKILLS, true);
test('should return undefined when chat_preview_features_enabled is false', async () => {
testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true);
instaService.stub(IDefaultAccountService, {
getDefaultAccount: () => Promise.resolve({ chat_preview_features_enabled: false } as IDefaultAccount)
});
const rootFolderName = 'claude-skills-test';
// Recreate service with new stub
service = disposables.add(instaService.createInstance(PromptsService));
const result = await service.findAgentSkills(CancellationToken.None);
assert.strictEqual(result, undefined);
// Restore default stub for other tests
instaService.stub(IDefaultAccountService, {
getDefaultAccount: () => Promise.resolve({ chat_preview_features_enabled: true } as IDefaultAccount)
});
});
test('should return undefined when USE_AGENT_SKILLS is enabled but chat_preview_features_enabled is false', async () => {
testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true);
instaService.stub(IDefaultAccountService, {
getDefaultAccount: () => Promise.resolve({ chat_preview_features_enabled: false } as IDefaultAccount)
});
// Recreate service with new stub
service = disposables.add(instaService.createInstance(PromptsService));
const result = await service.findAgentSkills(CancellationToken.None);
assert.strictEqual(result, undefined);
// Restore default stub for other tests
instaService.stub(IDefaultAccountService, {
getDefaultAccount: () => Promise.resolve({ chat_preview_features_enabled: true } as IDefaultAccount)
});
});
test('should find skills in workspace and user home', async () => {
testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true);
const rootFolderName = 'agent-skills-test';
const rootFolder = `/${rootFolderName}`;
const rootFolderUri = URI.file(rootFolder);
workspaceContextService.setWorkspace(testWorkspace(rootFolderUri));
// Create mock filesystem with skills
// Create mock filesystem with skills in both .github/skills and .claude/skills
await mockFiles(fileService, [
{
path: `${rootFolder}/.claude/skills/project-skill-1/SKILL.md`,
path: `${rootFolder}/.github/skills/github-skill-1/SKILL.md`,
contents: [
'---',
'name: "Project Skill 1"',
'description: "A project skill for testing"',
'name: "GitHub Skill 1"',
'description: "A GitHub skill for testing"',
'---',
'This is project skill 1 content',
'This is GitHub skill 1 content',
],
},
{
path: `${rootFolder}/.claude/skills/project-skill-2/SKILL.md`,
path: `${rootFolder}/.claude/skills/claude-skill-1/SKILL.md`,
contents: [
'---',
'name: "Claude Skill 1"',
'description: "A Claude skill for testing"',
'---',
'This is Claude skill 1 content',
],
},
{
path: `${rootFolder}/.claude/skills/invalid-skill/SKILL.md`,
contents: [
'---',
'description: "Invalid skill, no name"',
'---',
'This is project skill 2 content',
'This is invalid skill content',
],
},
{
path: `${rootFolder}/.claude/skills/not-a-skill-dir/README.md`,
path: `${rootFolder}/.github/skills/not-a-skill-dir/README.md`,
contents: ['This is not a skill'],
},
{
@@ -1317,19 +1369,24 @@ suite('PromptsService', () => {
},
]);
const result = await service.findClaudeSkills(CancellationToken.None);
const result = await service.findAgentSkills(CancellationToken.None);
assert.ok(result, 'Should return results when Claude skills are enabled');
assert.strictEqual(result.length, 2, 'Should find 2 skills total');
assert.ok(result, 'Should return results when agent skills are enabled');
assert.strictEqual(result.length, 3, 'Should find 3 skills total');
// Check project skills
// Check project skills (both from .github/skills and .claude/skills)
const projectSkills = result.filter(skill => skill.type === 'project');
assert.strictEqual(projectSkills.length, 1, 'Should find 1 project skill');
assert.strictEqual(projectSkills.length, 2, 'Should find 2 project skills');
const projectSkill1 = projectSkills.find(skill => skill.name === 'Project Skill 1');
assert.ok(projectSkill1, 'Should find project skill 1');
assert.strictEqual(projectSkill1.description, 'A project skill for testing');
assert.strictEqual(projectSkill1.uri.path, `${rootFolder}/.claude/skills/project-skill-1/SKILL.md`);
const githubSkill1 = projectSkills.find(skill => skill.name === 'GitHub Skill 1');
assert.ok(githubSkill1, 'Should find GitHub skill 1');
assert.strictEqual(githubSkill1.description, 'A GitHub skill for testing');
assert.strictEqual(githubSkill1.uri.path, `${rootFolder}/.github/skills/github-skill-1/SKILL.md`);
const claudeSkill1 = projectSkills.find(skill => skill.name === 'Claude Skill 1');
assert.ok(claudeSkill1, 'Should find Claude skill 1');
assert.strictEqual(claudeSkill1.description, 'A Claude skill for testing');
assert.strictEqual(claudeSkill1.uri.path, `${rootFolder}/.claude/skills/claude-skill-1/SKILL.md`);
// Check personal skills
const personalSkills = result.filter(skill => skill.type === 'personal');
@@ -1342,18 +1399,18 @@ suite('PromptsService', () => {
});
test('should handle parsing errors gracefully', async () => {
testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_SKILLS, true);
testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true);
const rootFolderName = 'claude-skills-error-test';
const rootFolderName = 'skills-error-test';
const rootFolder = `/${rootFolderName}`;
const rootFolderUri = URI.file(rootFolder);
workspaceContextService.setWorkspace(testWorkspace(rootFolderUri));
// Create mock filesystem with malformed skill file
// Create mock filesystem with malformed skill file in .github/skills
await mockFiles(fileService, [
{
path: `${rootFolder}/.claude/skills/valid-skill/SKILL.md`,
path: `${rootFolder}/.github/skills/valid-skill/SKILL.md`,
contents: [
'---',
'name: "Valid Skill"',
@@ -1373,7 +1430,7 @@ suite('PromptsService', () => {
},
]);
const result = await service.findClaudeSkills(CancellationToken.None);
const result = await service.findAgentSkills(CancellationToken.None);
// Should still return the valid skill, even if one has parsing errors
assert.ok(result, 'Should return results even with parsing errors');
@@ -1383,7 +1440,7 @@ suite('PromptsService', () => {
});
test('should return empty array when no skills found', async () => {
testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_SKILLS, true);
testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true);
const rootFolderName = 'empty-workspace';
const rootFolder = `/${rootFolderName}`;
@@ -1394,7 +1451,7 @@ suite('PromptsService', () => {
// Create empty mock filesystem
await mockFiles(fileService, []);
const result = await service.findClaudeSkills(CancellationToken.None);
const result = await service.findAgentSkills(CancellationToken.None);
assert.ok(result, 'Should return results array');
assert.strictEqual(result.length, 0, 'Should find no skills');