Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion src/vs/platform/agentHost/node/copilot/copilotAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDa
import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js';
import { ProtectedResourceMetadata, type AgentSelection, type ChildCustomizationType, type ConfigPropertySchema, type ConfigSchema, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js';
import { ActionType, type SessionAction } from '../../common/state/sessionActions.js';
import { AgentCustomization, CustomizationLoadStatus, CustomizationType, ResponsePartKind, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, isDefaultChatUri, parseChatUri, parseSubagentSessionUri, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ResponsePart, type ChatInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js';
import { AgentCustomization, CustomizationLoadStatus, CustomizationType, ResponsePartKind, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, isDefaultChatUri, parseChatUri, parseSubagentSessionUri, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type HookCustomization, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ResponsePart, type ChatInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js';
import { ActiveClientState } from '../activeClientState.js';
import { IAgentConfigurationService } from '../agentConfigurationService.js';
import { IAgentHostCompletions } from '../agentHostCompletions.js';
Expand Down Expand Up @@ -2539,6 +2539,8 @@ function toDirectoryContentsType(type: DiscoveredType): ChildCustomizationType {
case DiscoveredType.Instruction:
case DiscoveredType.AgentInstruction:
return CustomizationType.Rule;
case DiscoveredType.Hook:
return CustomizationType.Hook;
}
}

Expand Down Expand Up @@ -2583,6 +2585,15 @@ async function toDiscoveredChildCustomization(file: URI, type: DiscoveredType, f
};
return ruleCustomization;
}
if (type === DiscoveredType.Hook) {
const hookCustomization: HookCustomization = {
type: CustomizationType.Hook,
id,
uri,
name: resourceBasename(file),
};
return hookCustomization;
}
// agent instruction
return {
type: CustomizationType.Rule,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const enum DiscoveredType {
Agent = 'agent',
Skill = 'skill',
Instruction = 'instruction',
Hook = 'hook',
AgentInstruction = 'agentInstruction',
}

Expand Down Expand Up @@ -99,10 +100,12 @@ function compareDirectoryCustomization(a: DirectoryCustomization, b: DirectoryCu
* Maximum recursion depth when traversing subdirectories for instruction files.
*/
const MAX_INSTRUCTIONS_RECURSION_DEPTH = 5;
const MAX_HOOKS_RECURSION_DEPTH = 8;

const AGENT_FILE_SUFFIX = '.agent.md';
const MARKDOWN_SUFFIX = '.md';
const INSTRUCTION_FILE_SUFFIX = '.instructions.md';
const HOOK_FILE_SUFFIX = '.json';
const SKILL_FILENAME = 'SKILL.md';
const README_FILENAME = 'README.md';

Expand All @@ -112,15 +115,16 @@ interface ISearchRoot {
readonly recursive?: boolean; // whether to watch recursively for changes (defaults to false)
}

interface IInstructionFile {
interface IFixedDiscoveryFile {
readonly path: readonly string[];
readonly filenames: string[];
readonly type: DiscoveredType;
}

/**
* Builds the list of search roots for a given working directory and user home.
* Skills require a depth-2 scan (`<skillDir>/SKILL.md`); agents and instructions
* are flat single-directory scans.
* Skills require a depth-2 scan (`<skillDir>/SKILL.md`), agents are scanned at
* a single directory depth, and instructions/hooks are recursively scanned.
*/
const searchRoots: { workspace: ISearchRoot[]; user: ISearchRoot[] } = {
workspace: [
Expand All @@ -131,11 +135,13 @@ const searchRoots: { workspace: ISearchRoot[]; user: ISearchRoot[] } = {
{ path: ['.agents', 'skills'], recursive: true, type: DiscoveredType.Skill },
{ path: ['.claude', 'skills'], recursive: true, type: DiscoveredType.Skill },
{ path: ['.github', 'instructions'], recursive: true, type: DiscoveredType.Instruction },
{ path: ['.github', 'hooks'], recursive: true, type: DiscoveredType.Hook },
],
user: [
{ path: ['.copilot', 'agents'], type: DiscoveredType.Agent },
{ path: ['.agents', 'skills'], recursive: true, type: DiscoveredType.Skill },
{ path: ['.copilot', 'instructions'], recursive: true, type: DiscoveredType.Instruction },
{ path: ['.copilot', 'hooks'], recursive: true, type: DiscoveredType.Hook },
],
};

Expand All @@ -146,17 +152,22 @@ const searchRoots: { workspace: ISearchRoot[]; user: ISearchRoot[] } = {
* Returns paths with filenames for workspace and user-home
* locations
*/
const agentInstructions: { workspace: IInstructionFile[]; user: IInstructionFile[] } = {
const fixedDiscoveryFiles: { workspace: IFixedDiscoveryFile[]; user: IFixedDiscoveryFile[] } = {
workspace: [
{ path: ['.github'], filenames: ['copilot-instructions.md'] },
{ path: [], filenames: ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'] },
{ path: ['.claude'], filenames: ['CLAUDE.md'] },
{ path: ['.github'], filenames: ['copilot-instructions.md'], type: DiscoveredType.AgentInstruction },
{ path: [], filenames: ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'], type: DiscoveredType.AgentInstruction },
{ path: ['.claude'], filenames: ['CLAUDE.md'], type: DiscoveredType.AgentInstruction },
{ path: ['.github', 'copilot'], filenames: ['settings.json', 'settings.local.json'], type: DiscoveredType.Hook },
{ path: ['.claude'], filenames: ['settings.json', 'settings.local.json'], type: DiscoveredType.Hook },
],
user: [
{ path: ['.copilot'], filenames: ['copilot-instructions.md'] },
{ path: ['.copilot'], filenames: ['copilot-instructions.md'], type: DiscoveredType.AgentInstruction },
],
};

// Back-compat alias for tests and callers that referenced the old symbol name.
const agentInstructions = fixedDiscoveryFiles;

function throwIfCancelled(token: CancellationToken): void {
if (token.isCancellationRequested) {
throw new CancellationError();
Expand Down Expand Up @@ -186,7 +197,7 @@ function addWatch(map: ResourceMap<IWatchSpec>, watchUri: URI, recursive: boolea
}

/**
* Discovers customization files (agents, skills, and instructions)
* Discovers customization files (agents, skills, instructions, and hooks)
* under well-known directories of the session's working directory and the
* user's home, and emits {@link onDidChange} when any of those directories
* change on disk.
Expand Down Expand Up @@ -325,8 +336,8 @@ export class SessionCustomizationDiscovery extends Disposable {
await Promise.all([
...searchRoots.workspace.map(root => this._scanRoot(this._workingDirectory, root, seen, result, nextWatchRootUris, token)),
...searchRoots.user.map(root => this._scanRoot(this._userHome, root, seen, result, nextWatchRootUris, token)),
this._scanAgentInstructions(this._workingDirectory, agentInstructions.workspace, seen, result, nextWatchRootUris, token),
this._scanAgentInstructions(this._userHome, agentInstructions.user, seen, result, nextWatchRootUris, token)
this._scanFixedDiscoveryFiles(this._workingDirectory, fixedDiscoveryFiles.workspace, seen, result, nextWatchRootUris, token),
this._scanFixedDiscoveryFiles(this._userHome, fixedDiscoveryFiles.user, seen, result, nextWatchRootUris, token)
]);

throwIfCancelled(token);
Expand Down Expand Up @@ -405,10 +416,11 @@ export class SessionCustomizationDiscovery extends Disposable {
}

/**
* For agent instructions, create a single root for the base directory.
* For fixed discovery files (e.g. AGENTS.md, copilot-instructions.md,
* settings.json), create one discovered directory per type at the base.
*/
private async _scanAgentInstructions(base: URI, roots: IInstructionFile[], seen: ResourceSet, result: IDiscoveredDirectory[], watchRootUris: ResourceMap<IWatchSpec>, token: CancellationToken): Promise<void> {
const files: IDiscoveredFile[] = [];
private async _scanFixedDiscoveryFiles(base: URI, roots: IFixedDiscoveryFile[], seen: ResourceSet, result: IDiscoveredDirectory[], watchRootUris: ResourceMap<IWatchSpec>, token: CancellationToken): Promise<void> {
const filesByType = new Map<DiscoveredType, IDiscoveredFile[]>();
for (const root of roots) {
throwIfCancelled(token);

Expand Down Expand Up @@ -440,13 +452,19 @@ export class SessionCustomizationDiscovery extends Disposable {
const uri = joinPath(rootUri, entry.name);
if (!seen.has(uri)) {
seen.add(uri);
const files = filesByType.get(root.type) ?? [];
files.push({ uri, etag: entry.etag });
filesByType.set(root.type, files);
}
}
}
}
if (files.length > 0) {
result.push({ uri: base, type: DiscoveredType.AgentInstruction, files: files.sort(compareDiscoveredFile) });

for (const [type, files] of filesByType.entries()) {
if (files.length === 0) {
continue;
}
result.push({ uri: base, type, files: files.sort(compareDiscoveredFile) });
}
Comment on lines +463 to 468
}

Expand Down Expand Up @@ -539,6 +557,37 @@ export class SessionCustomizationDiscovery extends Disposable {
};
await findInstructions(stat, 0);
result.push({ uri: rootUri, type: root.type, files: files.sort(compareDiscoveredFile) });
} else if (root.type === DiscoveredType.Hook) {
const files: IDiscoveredFile[] = [];
// hooks are recursively discovered as `*.json` under the root.
const findHooks = async (directoryStat: IFileStatWithMetadata, recursionLevel: number): Promise<void> => {
throwIfCancelled(token);

for (const child of directoryStat.children ?? []) {
throwIfCancelled(token);

if (child.isFile) {
const name = child.name.toLowerCase();
if (name.endsWith(HOOK_FILE_SUFFIX) && !seen.has(child.resource)) {
seen.add(child.resource);
files.push({ uri: child.resource, etag: child.etag });
}
} else if (child.isDirectory && recursionLevel < MAX_HOOKS_RECURSION_DEPTH) {
let childStat: IFileStatWithMetadata | undefined = undefined;
try {
childStat = await this._fileService.resolve(child.resource, { resolveMetadata: true });
} catch {
// Ignore unreadable subdirectories.
}
if (childStat) {
await findHooks(childStat, recursionLevel + 1);
}
}
}
};

await findHooks(stat, 0);
result.push({ uri: rootUri, type: root.type, files: files.sort(compareDiscoveredFile) });
} else {
this._logService.warn(`[SessionCustomizationDiscovery] Unrecognized root type '${root.type}' for root '${rootUri.toString()}'`);
}
Expand All @@ -552,6 +601,7 @@ export const _internal = {
INSTRUCTION_FILE_SUFFIX,
SKILL_FILENAME,
searchRoots,
fixedDiscoveryFiles,
agentInstructions,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ function pluginDirForType(type: DiscoveredType): string | undefined {
case DiscoveredType.Agent: return 'agents';
case DiscoveredType.Skill: return 'skills';
case DiscoveredType.Instruction: return 'rules';
case DiscoveredType.Hook: return 'hooks';
case DiscoveredType.AgentInstruction: return undefined;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,11 +158,13 @@ suite('SessionCustomizationDiscovery', () => {
await seed('/workspace/.github/agents/foo.agent.md', 'workspace agent');
await seed('/workspace/.github/skills/bar/SKILL.md', 'workspace skill');
await seed('/workspace/.github/instructions/rules.instructions.md', 'workspace instruction');
await seed('/workspace/.github/hooks/pre-tool.json', '{"PreToolUse": []}');
await seed('/workspace/.github/copilot-instructions.md', 'workspace copilot instructions');
await seed('/workspace/.claude/CLAUDE.md', 'workspace claude instruction');
await seed('/home/.copilot/agents/user.agent.md', 'user agent');
await seed('/home/.agents/skills/user-skill/SKILL.md', 'user skill');
await seed('/home/.copilot/instructions/user.instructions.md', 'user instruction');
await seed('/home/.copilot/hooks/post-tool.json', '{"PostToolUse": []}');
await seed('/home/.copilot/copilot-instructions.md', 'user copilot instructions');

const watchCalls: Array<{ resource: string; recursive: boolean }> = [];
Expand All @@ -187,10 +189,12 @@ suite('SessionCustomizationDiscovery', () => {
assert.strictEqual(watched.get(URI.joinPath(workspace, '.github', 'agents').toString()), false);
assert.strictEqual(watched.get(URI.joinPath(workspace, '.github', 'skills').toString()), true);
assert.strictEqual(watched.get(URI.joinPath(workspace, '.github', 'instructions').toString()), true);
assert.strictEqual(watched.get(URI.joinPath(workspace, '.github', 'hooks').toString()), true);
assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot').toString()), false);
assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot', 'agents').toString()), false);
assert.strictEqual(watched.get(URI.joinPath(userHome, '.agents', 'skills').toString()), true);
assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot', 'instructions').toString()), true);
assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot', 'hooks').toString()), true);
});

test('refresh keeps existing watchers when discovered roots are unchanged', async () => {
Expand Down Expand Up @@ -354,29 +358,85 @@ suite('SessionCustomizationDiscovery', () => {
assert.ok(directories.some(directory => directory.type === DiscoveredType.Agent));
});

test('discovers agents, skills, and instructions across workspace and home roots', async () => {
test('discovers agents, skills, instructions, and hooks across workspace and home roots', async () => {
const wsAgent = await seed('/workspace/.github/agents/foo.agent.md', 'agent body');
const wsSkill = await seed('/workspace/.github/skills/bar/SKILL.md', 'skill body');
const wsInstr = await seed('/workspace/.github/instructions/baz.instructions.md', 'instr body');
const wsHook = await seed('/workspace/.github/hooks/pre-tool.json', '{"PreToolUse": []}');
const userAgent = await seed('/home/.copilot/agents/qux.agent.md', 'user agent');
const userSkill = await seed('/home/.agents/skills/zap/SKILL.md', 'user skill');
const userHook = await seed('/home/.copilot/hooks/post-tool.json', '{"PostToolUse": []}');
// Noise that should not be picked up
await seed('/workspace/.github/agents/not-an-agent.txt', 'ignored');
await seed('/workspace/.github/hooks/not-a-hook.md', 'ignored');

const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome));
const directories = await discovery.scan(CancellationToken.None);
const files = directories.flatMap(directory => directory.files.map(file => ({ uri: file.uri, type: directory.type })));

assert.deepStrictEqual([...files].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString())), [
{ uri: userAgent, type: DiscoveredType.Agent },
{ uri: userHook, type: DiscoveredType.Hook },
{ uri: userSkill, type: DiscoveredType.Skill },
{ uri: wsAgent, type: DiscoveredType.Agent },
{ uri: wsHook, type: DiscoveredType.Hook },
{ uri: wsInstr, type: DiscoveredType.Instruction },
{ uri: wsSkill, type: DiscoveredType.Skill },
].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString())));
assert.ok(directories.some(directory => directory.uri.toString() === URI.joinPath(workspace, '.github', 'agents').toString()));
});

test('discovers nested .json hook files', async () => {
const nestedWsHook = await seed('/workspace/.github/hooks/team/security/pre-tool.json', '{"PreToolUse": []}');
const nestedUserHook = await seed('/home/.copilot/hooks/domain/tools/post-tool.json', '{"PostToolUse": []}');

const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome));
const files = (await discovery.scan(CancellationToken.None)).flatMap(directory => directory.files.map(file => ({ uri: file.uri, type: directory.type })));

assert.deepStrictEqual([...files].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString())), [
{ uri: nestedUserHook, type: DiscoveredType.Hook },
{ uri: nestedWsHook, type: DiscoveredType.Hook },
].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString())));
});

test('discovers hook settings files from fixed workspace locations', async () => {
const githubSettings = await seed('/workspace/.github/copilot/settings.json', '{"hooks": {"PreToolUse": []}}');
const githubLocalSettings = await seed('/workspace/.github/copilot/settings.local.json', '{"hooks": {"PostToolUse": []}}');
const claudeSettings = await seed('/workspace/.claude/settings.json', '{"hooks": {"SessionStart": []}}');
const claudeLocalSettings = await seed('/workspace/.claude/settings.local.json', '{"hooks": {"SessionEnd": []}}');
await seed('/workspace/.github/copilot/settings.dev.json', '{"hooks": {"Ignored": []}}');

const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome));
const files = (await discovery.scan(CancellationToken.None)).flatMap(directory => directory.files.map(file => ({ uri: file.uri, type: directory.type })));

assert.deepStrictEqual([...files].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString())), [
{ uri: claudeLocalSettings, type: DiscoveredType.Hook },
{ uri: claudeSettings, type: DiscoveredType.Hook },
{ uri: githubLocalSettings, type: DiscoveredType.Hook },
{ uri: githubSettings, type: DiscoveredType.Hook },
].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString())));
});

test('fires onDidChange when fixed hook settings file is modified', async () => {
await seed('/workspace/.github/copilot/settings.json', '{"hooks": {"PreToolUse": []}}');

const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome));
await discovery.scan(CancellationToken.None);
await timeout(50);

let changeCount = 0;
const fired = new DeferredPromise<void>();
disposables.add(discovery.onDidChange(() => {
changeCount++;
fired.complete();
}));

await seed('/workspace/.github/copilot/settings.json', '{"hooks": {"PreToolUse": [{"command": "echo test"}]}}');
await raceTimeout(fired.p, 500);

assert.strictEqual(changeCount, 1, 'expected onDidChange to fire when fixed hook settings file is modified');
});

test('excludes exact-case README.md inside agent folders', async () => {
const wsAgent = await seed('/workspace/.github/agents/foo.agent.md', 'agent body');
const wsPlainAgent = await seed('/workspace/.github/agents/plain.md', 'plain agent body');
Expand Down Expand Up @@ -558,6 +618,7 @@ suite('SessionPluginBundler', () => {
await seed('/workspace/.github/agents/foo.agent.md', 'agent body');
await seed('/workspace/.github/skills/bar/SKILL.md', 'skill body');
await seed('/workspace/.github/instructions/baz.instructions.md', 'instr body');
await seed('/workspace/.github/hooks/pre-tool.json', '{"PreToolUse": []}');

const discovery = disposables.add(instantiationService.createInstance(SessionCustomizationDiscovery, workspace, userHome));
const bundler = disposables.add(instantiationService.createInstance(SessionPluginBundler, workspace));
Expand All @@ -580,6 +641,9 @@ suite('SessionPluginBundler', () => {

const instr = await fileService.readFile(URI.joinPath(root, 'rules', 'baz.instructions.md'));
assert.strictEqual(instr.value.toString(), 'instr body');

const hook = await fileService.readFile(URI.joinPath(root, 'hooks', 'pre-tool.json'));
assert.strictEqual(hook.value.toString(), '{"PreToolUse": []}');
});


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,8 @@ function toPromptsType(type: ChildCustomization['type']): PromptsType | undefine
return PromptsType.instructions;
case CustomizationType.Prompt:
return PromptsType.prompt;
case CustomizationType.Hook:
return PromptsType.hook;
default:
return undefined;
}
Expand Down