plugin system: add support for rules/instructions (#301172)

* plugin system: add support for rules ('instructions') from Open Plugin spec

- Adds IAgentPluginInstruction interface and instructions property to IAgentPlugin
  observable stream, following the same pattern as commands/skills/agents
- Implements _readRules() method in agentPluginServiceImpl to discover rule files
  (.mdc, .md, .instructions.md) from the rules/ directory and supplemental paths
  defined in the plugin manifest. Uses longest-match-first suffix stripping to
  correctly derive rule names.
- Wires observeComponent('rules', ...) in _toPlugin() to integrate manifest
  'rules' field configuration with the discovery mechanism
- Adds plugin instructions to the prompt file discovery system via watchPluginPromptFilesForType,
  making instructions available alongside filesystem-discovered instructions
- Includes comprehensive test coverage for rule discovery patterns, suffix stripping,
  deduplication, and reactive observable integration

(Commit message generated by Copilot)

* comments
This commit is contained in:
Connor Peet
2026-03-12 11:25:23 -07:00
committed by GitHub
parent 8c731add1f
commit 2d08838052
5 changed files with 315 additions and 2 deletions

View File

@@ -38,6 +38,11 @@ export interface IAgentPluginAgent {
readonly name: string;
}
export interface IAgentPluginInstruction {
readonly uri: URI;
readonly name: string;
}
export interface IAgentPluginMcpServerDefinition {
readonly name: string;
readonly configuration: IMcpServerConfiguration;
@@ -54,6 +59,7 @@ export interface IAgentPlugin {
readonly commands: IObservable<readonly IAgentPluginCommand[]>;
readonly skills: IObservable<readonly IAgentPluginSkill[]>;
readonly agents: IObservable<readonly IAgentPluginAgent[]>;
readonly instructions: IObservable<readonly IAgentPluginInstruction[]>;
readonly mcpServerDefinitions: IObservable<readonly IAgentPluginMcpServerDefinition[]>;
/** Set when the plugin was installed from a marketplace repository. */
readonly fromMarketplace?: IMarketplacePlugin;

View File

@@ -37,11 +37,14 @@ import { parseClaudeHooks } from '../promptSyntax/hookClaudeCompat.js';
import { parseCopilotHooks } from '../promptSyntax/hookCompatibility.js';
import { IHookCommand } from '../promptSyntax/hookSchema.js';
import { IAgentPluginRepositoryService } from './agentPluginRepositoryService.js';
import { agentPluginDiscoveryRegistry, IAgentPlugin, IAgentPluginDiscovery, IAgentPluginHook, IAgentPluginMcpServerDefinition, IAgentPluginService, IAgentPluginSkill } from './agentPluginService.js';
import { agentPluginDiscoveryRegistry, IAgentPlugin, IAgentPluginDiscovery, IAgentPluginHook, IAgentPluginInstruction, IAgentPluginMcpServerDefinition, IAgentPluginService, IAgentPluginSkill } from './agentPluginService.js';
import { IMarketplacePlugin, IPluginMarketplaceService } from './pluginMarketplaceService.js';
const COMMAND_FILE_SUFFIX = '.md';
/** File suffixes accepted for rule/instruction files (longest first for correct name stripping). */
const RULE_FILE_SUFFIXES = ['.instructions.md', '.mdc', '.md'];
const enum AgentPluginFormat {
Copilot,
Claude,
@@ -509,6 +512,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements
const commands = observeComponent('commands', d => this._readMarkdownComponents(d));
const skills = observeComponent('skills', d => this._readSkills(d));
const agents = observeComponent('agents', d => this._readMarkdownComponents(d));
const instructions = observeComponent('rules', d => this._readRules(d));
const hooks = observeComponent(
'hooks',
paths => this._readHooksFromPaths(uri, paths, adapter),
@@ -549,6 +553,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements
commands,
skills,
agents,
instructions,
mcpServerDefinitions,
fromMarketplace,
};
@@ -737,6 +742,62 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements
return skills;
}
/**
* Scans directories for rule/instruction files (`.mdc`, `.md`,
* `.instructions.md`), returning `{ uri, name }` entries where name is
* derived from the filename minus the matched suffix.
*/
private async _readRules(dirs: readonly URI[]): Promise<readonly IAgentPluginInstruction[]> {
const seen = new Set<string>();
const items: IAgentPluginInstruction[] = [];
const matchSuffix = (filename: string): string | undefined => {
const lower = filename.toLowerCase();
return RULE_FILE_SUFFIXES.find(s => lower.endsWith(s));
};
const addItem = (name: string, uri: URI) => {
if (!seen.has(name)) {
seen.add(name);
items.push({ uri, name });
}
};
for (const dir of dirs) {
let stat;
try {
stat = await this._fileService.resolve(dir);
} catch {
continue;
}
if (stat.isFile) {
const suffix = matchSuffix(basename(dir));
if (suffix) {
addItem(basename(dir).slice(0, -suffix.length), dir);
}
continue;
}
if (!stat.isDirectory || !stat.children) {
continue;
}
for (const child of stat.children) {
if (!child.isFile) {
continue;
}
const suffix = matchSuffix(child.name);
if (suffix) {
addItem(child.name.slice(0, -suffix.length), child.resource);
}
}
}
items.sort((a, b) => a.name.localeCompare(b.name));
return items;
}
/**
* Scans directories for `.md` files, returning `{ uri, name }` entries
* where name is derived from the filename (minus the `.md` extension).

View File

@@ -214,6 +214,7 @@ export class PromptsService extends Disposable implements IPromptsService {
Event.filter(modelChangeEvent, e => e.promptType === PromptsType.agent),
this._onDidContributedWhenChange.event,
Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(PromptsConfig.USE_CUSTOM_AGENT_HOOKS)),
this._onDidPluginPromptFilesChange.event,
)
));
@@ -259,6 +260,10 @@ export class PromptsService extends Disposable implements IPromptsService {
PromptsType.agent,
(plugin, reader) => plugin.agents.read(reader),
));
this._register(this.watchPluginPromptFilesForType(
PromptsType.instructions,
(plugin, reader) => plugin.instructions.read(reader),
));
this._register(autorun(reader => {
const plugins = this.agentPluginService.plugins.read(reader);
@@ -671,6 +676,7 @@ export class PromptsService extends Disposable implements IPromptsService {
this.getFileLocatorEvent(PromptsType.instructions),
this._onDidContributedWhenChange.event,
this._onDidChangeInstructions.event,
this._onDidPluginPromptFilesChange.event,
);
}

View File

@@ -401,6 +401,7 @@ suite('AgentPlugin format detection', () => {
await writeFile('/plugins/no-manifest/commands/hello.md', '# Hello');
await writeFile('/plugins/no-manifest/skills/my-skill/SKILL.md', '# My skill');
await writeFile('/plugins/no-manifest/agents/helper.md', '# Helper');
await writeFile('/plugins/no-manifest/rules/prefer-const.mdc', '---\ndescription: Prefer const\n---\nUse const.');
const discovery = createDiscovery();
discovery.start(mockEnablementModel);
@@ -418,6 +419,9 @@ suite('AgentPlugin format detection', () => {
await waitForState(plugins[0].agents, a => a.length > 0);
assert.strictEqual(plugins[0].agents.get()[0].name, 'helper');
await waitForState(plugins[0].instructions, i => i.length > 0);
assert.strictEqual(plugins[0].instructions.get()[0].name, 'prefer-const');
}));
test('reads hooks from default hooks/hooks.json', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
@@ -701,4 +705,132 @@ suite('AgentPlugin format detection', () => {
await waitForState(plugins[0].mcpServerDefinitions, d => d.length > 0);
assert.strictEqual(plugins[0].mcpServerDefinitions.get()[0].name, 'custom-server');
}));
test('reads rules from rules/ directory with .mdc extension', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
const uri = pluginUri('/plugins/rules-plugin');
await writeFile('/plugins/rules-plugin/.plugin/plugin.json', JSON.stringify({ name: 'rules-plugin' }));
await writeFile('/plugins/rules-plugin/rules/prefer-const.mdc', '---\ndescription: Prefer const\n---\nUse const.');
await writeFile('/plugins/rules-plugin/rules/error-handling.mdc', '---\ndescription: Error handling\n---\nAlways handle errors.');
const discovery = createDiscovery();
discovery.start(mockEnablementModel);
await discovery.setSourcesAndRefresh([uri]);
const plugins = discovery.plugins.get();
assert.strictEqual(plugins.length, 1);
await waitForState(plugins[0].instructions, i => i.length >= 2);
assert.deepStrictEqual(
plugins[0].instructions.get().map(i => i.name).sort(),
['error-handling', 'prefer-const'],
);
}));
test('reads rules with .md and .instructions.md extensions', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
const uri = pluginUri('/plugins/rules-mixed');
await writeFile('/plugins/rules-mixed/.plugin/plugin.json', JSON.stringify({ name: 'rules-mixed' }));
await writeFile('/plugins/rules-mixed/rules/rule-a.mdc', 'Rule A');
await writeFile('/plugins/rules-mixed/rules/rule-b.md', 'Rule B');
await writeFile('/plugins/rules-mixed/rules/rule-c.instructions.md', 'Rule C');
const discovery = createDiscovery();
discovery.start(mockEnablementModel);
await discovery.setSourcesAndRefresh([uri]);
const plugins = discovery.plugins.get();
assert.strictEqual(plugins.length, 1);
await waitForState(plugins[0].instructions, i => i.length >= 3);
assert.deepStrictEqual(
plugins[0].instructions.get().map(i => i.name).sort(),
['rule-a', 'rule-b', 'rule-c'],
);
}));
test('manifest rules field adds supplemental rule directories', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
const uri = pluginUri('/plugins/custom-rules');
await writeFile('/plugins/custom-rules/.plugin/plugin.json', JSON.stringify({
name: 'custom-rules',
rules: './extra-rules/',
}));
await writeFile('/plugins/custom-rules/rules/default-rule.mdc', 'Default rule');
await writeFile('/plugins/custom-rules/extra-rules/bonus-rule.mdc', 'Bonus rule');
const discovery = createDiscovery();
discovery.start(mockEnablementModel);
await discovery.setSourcesAndRefresh([uri]);
const plugins = discovery.plugins.get();
assert.strictEqual(plugins.length, 1);
await waitForState(plugins[0].instructions, i => i.length >= 2);
assert.deepStrictEqual(
plugins[0].instructions.get().map(i => i.name).sort(),
['bonus-rule', 'default-rule'],
);
}));
test('manifest rules field with exclusive mode skips default directory', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
const uri = pluginUri('/plugins/exclusive-rules');
await writeFile('/plugins/exclusive-rules/.plugin/plugin.json', JSON.stringify({
name: 'exclusive-rules',
rules: { paths: ['./only-here/'], exclusive: true },
}));
await writeFile('/plugins/exclusive-rules/rules/ignored.mdc', 'Should be ignored');
await writeFile('/plugins/exclusive-rules/only-here/visible.mdc', 'Should be visible');
const discovery = createDiscovery();
discovery.start(mockEnablementModel);
await discovery.setSourcesAndRefresh([uri]);
const plugins = discovery.plugins.get();
assert.strictEqual(plugins.length, 1);
await waitForState(plugins[0].instructions, i => i.length === 1 && i[0].name === 'visible');
assert.deepStrictEqual(
plugins[0].instructions.get().map(i => i.name),
['visible'],
);
}));
test('rule name strips longest matching suffix first', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
const uri = pluginUri('/plugins/suffix-rules');
await writeFile('/plugins/suffix-rules/.plugin/plugin.json', JSON.stringify({ name: 'suffix-rules' }));
await writeFile('/plugins/suffix-rules/rules/coding-standards.instructions.md', 'Standards');
const discovery = createDiscovery();
discovery.start(mockEnablementModel);
await discovery.setSourcesAndRefresh([uri]);
const plugins = discovery.plugins.get();
assert.strictEqual(plugins.length, 1);
await waitForState(plugins[0].instructions, i => i.length > 0);
// Should strip '.instructions.md' (longest match), not just '.md'
assert.strictEqual(plugins[0].instructions.get()[0].name, 'coding-standards');
}));
test('deduplicates rules with the same base name', () => runWithFakedTimers({ useFakeTimers: true }, async () => {
const uri = pluginUri('/plugins/dup-rules');
await writeFile('/plugins/dup-rules/.plugin/plugin.json', JSON.stringify({
name: 'dup-rules',
rules: './extra/',
}));
// Default directory has 'my-rule.mdc', supplemental has 'my-rule.md' — first wins
await writeFile('/plugins/dup-rules/rules/my-rule.mdc', 'From default');
await writeFile('/plugins/dup-rules/extra/my-rule.md', 'From extra');
const discovery = createDiscovery();
discovery.start(mockEnablementModel);
await discovery.setSourcesAndRefresh([uri]);
const plugins = discovery.plugins.get();
assert.strictEqual(plugins.length, 1);
await waitForState(plugins[0].instructions, i => i.length > 0);
assert.strictEqual(plugins[0].instructions.get().length, 1);
const instruction = plugins[0].instructions.get()[0];
assert.strictEqual(instruction.name, 'my-rule');
assert.ok(instruction.uri.path.endsWith('/rules/my-rule.mdc'));
}));
});

View File

@@ -53,7 +53,7 @@ import { ChatModeKind } from '../../../../common/constants.js';
import { HookType } from '../../../../common/promptSyntax/hookTypes.js';
import { IContextKeyService, IContextKeyChangeEvent } from '../../../../../../../platform/contextkey/common/contextkey.js';
import { MockContextKeyService } from '../../../../../../../platform/keybinding/test/common/mockKeybindingService.js';
import { IAgentPlugin, IAgentPluginAgent, IAgentPluginCommand, IAgentPluginHook, IAgentPluginMcpServerDefinition, IAgentPluginService, IAgentPluginSkill } from '../../../../common/plugins/agentPluginService.js';
import { IAgentPlugin, IAgentPluginAgent, IAgentPluginCommand, IAgentPluginHook, IAgentPluginInstruction, IAgentPluginMcpServerDefinition, IAgentPluginService, IAgentPluginSkill } from '../../../../common/plugins/agentPluginService.js';
import { IWorkspaceTrustManagementService } from '../../../../../../../platform/workspace/common/workspaceTrust.js';
suite('PromptsService', () => {
@@ -3684,6 +3684,7 @@ suite('PromptsService', () => {
const commands = observableValue<readonly IAgentPluginCommand[]>('testPluginCommands', []);
const skills = observableValue<readonly IAgentPluginSkill[]>('testPluginSkills', []);
const agents = observableValue<readonly IAgentPluginAgent[]>('testPluginAgents', []);
const instructions = observableValue<readonly IAgentPluginInstruction[]>('testPluginInstructions', []);
const mcpServerDefinitions = observableValue<readonly IAgentPluginMcpServerDefinition[]>('testPluginMcpServerDefinitions', []);
return {
@@ -3696,6 +3697,7 @@ suite('PromptsService', () => {
commands,
skills,
agents,
instructions,
mcpServerDefinitions,
},
hooks,
@@ -3855,4 +3857,110 @@ suite('PromptsService', () => {
assert.strictEqual(result, undefined, 'Expected undefined hooks when workspace is untrusted, even with plugin hooks');
});
});
suite('plugin instructions', () => {
function createPluginWithInstructions(
path: string,
initialInstructions: readonly IAgentPluginInstruction[],
): { plugin: IAgentPlugin; instructions: ISettableObservable<readonly IAgentPluginInstruction[]> } {
const enablement = observableValue('testPluginEnablement', 2 /* ContributionEnablementState.EnabledProfile */);
const hooks = observableValue<readonly IAgentPluginHook[]>('testPluginHooks', []);
const commands = observableValue<readonly IAgentPluginCommand[]>('testPluginCommands', []);
const skills = observableValue<readonly IAgentPluginSkill[]>('testPluginSkills', []);
const agents = observableValue<readonly IAgentPluginAgent[]>('testPluginAgents', []);
const instructions = observableValue<readonly IAgentPluginInstruction[]>('testPluginInstructions', initialInstructions);
const mcpServerDefinitions = observableValue<readonly IAgentPluginMcpServerDefinition[]>('testPluginMcpServerDefinitions', []);
return {
plugin: {
uri: URI.file(path),
label: basename(URI.file(path)),
enablement,
remove: () => { },
hooks,
commands,
skills,
agents,
instructions,
mcpServerDefinitions,
},
instructions,
};
}
test('lists plugin instructions via listPromptFiles', async function () {
const ruleUri = URI.file('/plugins/test-plugin/rules/prefer-const.mdc');
const { plugin } = createPluginWithInstructions('/plugins/test-plugin', [
{ uri: ruleUri, name: 'prefer-const' },
]);
testPluginsObservable.set([plugin], undefined);
const result = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None);
const pluginInstruction = result.find(p => p.uri.toString() === ruleUri.toString());
assert.ok(pluginInstruction, 'Plugin instruction should appear in listPromptFiles');
assert.strictEqual(pluginInstruction!.storage, PromptsStorage.plugin);
});
test('updates listed instructions when plugin instructions change', async function () {
const ruleUri1 = URI.file('/plugins/test-plugin/rules/rule-a.mdc');
const ruleUri2 = URI.file('/plugins/test-plugin/rules/rule-b.mdc');
const { plugin, instructions } = createPluginWithInstructions('/plugins/test-plugin', [
{ uri: ruleUri1, name: 'rule-a' },
]);
testPluginsObservable.set([plugin], undefined);
const before = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None);
const beforePlugin = before.filter(p => p.storage === PromptsStorage.plugin);
assert.strictEqual(beforePlugin.length, 1);
const eventFired = new Promise<void>(resolve => {
const disposable = service.onDidChangeInstructions(() => {
disposable.dispose();
resolve();
});
});
instructions.set([
{ uri: ruleUri1, name: 'rule-a' },
{ uri: ruleUri2, name: 'rule-b' },
], undefined);
await eventFired;
const after = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None);
const afterPlugin = after.filter(p => p.storage === PromptsStorage.plugin);
assert.strictEqual(afterPlugin.length, 2);
});
test('removes instructions when plugin is removed', async function () {
const ruleUri = URI.file('/plugins/test-plugin/rules/rule-a.mdc');
const { plugin } = createPluginWithInstructions('/plugins/test-plugin', [
{ uri: ruleUri, name: 'rule-a' },
]);
testPluginsObservable.set([plugin], undefined);
const withPlugin = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None);
assert.ok(withPlugin.some(p => p.storage === PromptsStorage.plugin));
testPluginsObservable.set([], undefined);
const withoutPlugin = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None);
assert.ok(!withoutPlugin.some(p => p.storage === PromptsStorage.plugin));
});
test('namespaces plugin instruction names with plugin folder', async function () {
const ruleUri = URI.file('/plugins/deploy-tools/rules/lint-check.mdc');
const { plugin } = createPluginWithInstructions('/plugins/deploy-tools', [
{ uri: ruleUri, name: 'lint-check' },
]);
testPluginsObservable.set([plugin], undefined);
const result = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None);
const pluginInstruction = result.find(p => p.uri.toString() === ruleUri.toString());
assert.ok(pluginInstruction, 'Plugin instruction should be listed');
assert.strictEqual(pluginInstruction!.name, 'deploy-tools:lint-check');
});
});
});