Skip to content

Commit ffe529e

Browse files
authored
Add support for agent-scoped hooks (#299029)
1 parent 1b44525 commit ffe529e

19 files changed

Lines changed: 1982 additions & 115 deletions

File tree

src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ import { IPathService } from '../../../../services/path/common/pathService.js';
4343
import { generateCustomizationDebugReport } from './aiCustomizationDebugPanel.js';
4444
import { parseHooksFromFile } from '../../common/promptSyntax/hookCompatibility.js';
4545
import { formatHookCommandLabel } from '../../common/promptSyntax/hookSchema.js';
46-
import { HOOK_METADATA } from '../../common/promptSyntax/hookTypes.js';
46+
import { HookType, HOOK_METADATA } from '../../common/promptSyntax/hookTypes.js';
4747
import { parse as parseJSONC } from '../../../../../base/common/json.js';
4848
import { Schemas } from '../../../../../base/common/network.js';
4949
import { OS } from '../../../../../base/common/platform.js';
@@ -65,6 +65,8 @@ export interface IAICustomizationListItem {
6565
readonly description?: string;
6666
readonly storage: PromptsStorage;
6767
readonly promptType: PromptsType;
68+
/** When set, overrides `storage` for display grouping purposes. */
69+
readonly groupKey?: string;
6870
nameMatches?: IMatch[];
6971
descriptionMatches?: IMatch[];
7072
}
@@ -75,7 +77,7 @@ export interface IAICustomizationListItem {
7577
interface IGroupHeaderEntry {
7678
readonly type: 'group-header';
7779
readonly id: string;
78-
readonly storage: PromptsStorage;
80+
readonly groupKey: string;
7981
readonly label: string;
8082
readonly icon: ThemeIcon;
8183
readonly count: number;
@@ -311,7 +313,7 @@ export class AICustomizationListWidget extends Disposable {
311313
private allItems: IAICustomizationListItem[] = [];
312314
private displayEntries: IListEntry[] = [];
313315
private searchQuery: string = '';
314-
private readonly collapsedGroups = new Set<PromptsStorage>();
316+
private readonly collapsedGroups = new Set<string>();
315317
private readonly dropdownActionDisposables = this._register(new DisposableStore());
316318

317319
private readonly delayedFilter = new Delayer<void>(200);
@@ -827,6 +829,37 @@ export class AICustomizationListWidget extends Disposable {
827829
});
828830
}
829831
}
832+
833+
// Also include hooks defined in agent frontmatter (not in sessions window)
834+
// TODO: add this back when Copilot CLI supports this
835+
const agents = !this.workspaceService.isSessionsWindow ? await this.promptsService.getCustomAgents(CancellationToken.None) : [];
836+
for (const agent of agents) {
837+
if (!agent.hooks) {
838+
continue;
839+
}
840+
for (const hookType of Object.values(HookType)) {
841+
const hookCommands = agent.hooks[hookType];
842+
if (!hookCommands || hookCommands.length === 0) {
843+
continue;
844+
}
845+
const hookMeta = HOOK_METADATA[hookType];
846+
for (let i = 0; i < hookCommands.length; i++) {
847+
const hook = hookCommands[i];
848+
const cmdLabel = formatHookCommandLabel(hook, OS);
849+
const truncatedCmd = cmdLabel.length > 60 ? cmdLabel.substring(0, 57) + '...' : cmdLabel;
850+
items.push({
851+
id: `${agent.uri.toString()}#hook:${hookType}[${i}]`,
852+
uri: agent.uri,
853+
name: hookMeta?.label ?? hookType,
854+
filename: basename(agent.uri),
855+
description: `${agent.name}: ${truncatedCmd || localize('hookUnset', "(unset)")}`,
856+
storage: agent.source.storage,
857+
groupKey: 'agents',
858+
promptType,
859+
});
860+
}
861+
}
862+
}
830863
} else {
831864
// For instructions, fetch prompt files and group by storage
832865
const promptFiles = await this.promptsService.listPromptFiles(promptType, CancellationToken.None);
@@ -940,15 +973,17 @@ export class AICustomizationListWidget extends Disposable {
940973
// Group items by storage
941974
const promptType = sectionToPromptType(this.currentSection);
942975
const visibleSources = new Set(this.workspaceService.getStorageSourceFilter(promptType).sources);
943-
const groups: { storage: PromptsStorage; label: string; icon: ThemeIcon; description: string; items: IAICustomizationListItem[] }[] = [
944-
{ storage: PromptsStorage.local, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "Customizations stored as files in your project folder and shared with your team via version control."), items: [] },
945-
{ storage: PromptsStorage.user, label: localize('userGroup', "User"), icon: userIcon, description: localize('userGroupDescription', "Customizations stored locally on your machine in a central location. Private to you and available across all projects."), items: [] },
946-
{ storage: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, description: localize('extensionGroupDescription', "Read-only customizations provided by installed extensions."), items: [] },
947-
{ storage: PromptsStorage.plugin, label: localize('pluginGroup', "Plugins"), icon: pluginIcon, description: localize('pluginGroupDescription', "Read-only customizations provided by installed plugins."), items: [] },
948-
].filter(g => visibleSources.has(g.storage));
976+
const groups: { groupKey: string; label: string; icon: ThemeIcon; description: string; items: IAICustomizationListItem[] }[] = [
977+
{ groupKey: PromptsStorage.local, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "Customizations stored as files in your project folder and shared with your team via version control."), items: [] },
978+
{ groupKey: PromptsStorage.user, label: localize('userGroup', "User"), icon: userIcon, description: localize('userGroupDescription', "Customizations stored locally on your machine in a central location. Private to you and available across all projects."), items: [] },
979+
{ groupKey: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, description: localize('extensionGroupDescription', "Read-only customizations provided by installed extensions."), items: [] },
980+
{ groupKey: PromptsStorage.plugin, label: localize('pluginGroup', "Plugins"), icon: pluginIcon, description: localize('pluginGroupDescription', "Read-only customizations provided by installed plugins."), items: [] },
981+
{ groupKey: 'agents', label: localize('agentsGroup', "Agents"), icon: agentIcon, description: localize('agentsGroupDescription', "Hooks defined in agent files."), items: [] },
982+
].filter(g => visibleSources.has(g.groupKey as PromptsStorage) || g.groupKey === 'agents');
949983

950984
for (const item of matchedItems) {
951-
const group = groups.find(g => g.storage === item.storage);
985+
const key = item.groupKey ?? item.storage;
986+
const group = groups.find(g => g.groupKey === key);
952987
if (group) {
953988
group.items.push(item);
954989
}
@@ -967,12 +1002,12 @@ export class AICustomizationListWidget extends Disposable {
9671002
continue;
9681003
}
9691004

970-
const collapsed = this.collapsedGroups.has(group.storage);
1005+
const collapsed = this.collapsedGroups.has(group.groupKey);
9711006

9721007
this.displayEntries.push({
9731008
type: 'group-header',
974-
id: `group-${group.storage}`,
975-
storage: group.storage,
1009+
id: `group-${group.groupKey}`,
1010+
groupKey: group.groupKey,
9761011
label: group.label,
9771012
icon: group.icon,
9781013
count: group.items.length,
@@ -997,10 +1032,10 @@ export class AICustomizationListWidget extends Disposable {
9971032
* Toggles the collapsed state of a group.
9981033
*/
9991034
private toggleGroup(entry: IGroupHeaderEntry): void {
1000-
if (this.collapsedGroups.has(entry.storage)) {
1001-
this.collapsedGroups.delete(entry.storage);
1035+
if (this.collapsedGroups.has(entry.groupKey)) {
1036+
this.collapsedGroups.delete(entry.groupKey);
10021037
} else {
1003-
this.collapsedGroups.add(entry.storage);
1038+
this.collapsedGroups.add(entry.groupKey);
10041039
}
10051040
this.filterItems();
10061041
}

src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,14 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js';
2424
import { IQuickInputButton, IQuickInputService, IQuickPick, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js';
2525
import { IFileService } from '../../../../../platform/files/common/files.js';
2626
import { HOOK_METADATA, HOOKS_BY_TARGET, HookType, IHookTypeMeta } from '../../common/promptSyntax/hookTypes.js';
27-
import { getEffectiveCommandFieldKey } from '../../common/promptSyntax/hookSchema.js';
27+
import { formatHookCommandLabel, getEffectiveCommandFieldKey } from '../../common/promptSyntax/hookSchema.js';
2828
import { getCopilotCliHookTypeName, resolveCopilotCliHookType } from '../../common/promptSyntax/hookCopilotCliCompat.js';
2929
import { getHookSourceFormat, HookSourceFormat, buildNewHookEntry } from '../../common/promptSyntax/hookCompatibility.js';
3030
import { getClaudeHookTypeName, resolveClaudeHookType } from '../../common/promptSyntax/hookClaudeCompat.js';
3131
import { ILabelService } from '../../../../../platform/label/common/label.js';
3232
import { IEditorService } from '../../../../services/editor/common/editorService.js';
3333
import { ITextEditorSelection } from '../../../../../platform/editor/common/editor.js';
34-
import { findHookCommandSelection, parseAllHookFiles, IParsedHook } from './hookUtils.js';
34+
import { findHookCommandSelection, findHookCommandInYaml, parseAllHookFiles, IParsedHook } from './hookUtils.js';
3535
import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js';
3636
import { IPathService } from '../../../../services/path/common/pathService.js';
3737
import { INotificationService } from '../../../../../platform/notification/common/notification.js';
@@ -348,7 +348,8 @@ export async function showConfigureHooksQuickPick(
348348
workspaceRootUri,
349349
userHome,
350350
targetOS,
351-
CancellationToken.None
351+
CancellationToken.None,
352+
{ includeAgentHooks: true }
352353
);
353354

354355
// Count hooks per type
@@ -445,6 +446,10 @@ export async function showConfigureHooksQuickPick(
445446
// Filter hooks by the selected type
446447
const hooksOfType = hookEntries.filter(h => h.hookType === selectedHookType!.hookType);
447448

449+
// Separate hooks by source
450+
const fileHooks = hooksOfType.filter(h => !h.agentName);
451+
const agentHooks = hooksOfType.filter(h => h.agentName);
452+
448453
// Step 2: Show "Add new hook" + existing hooks of this type
449454
const hookItems: (IHookQuickPickItem | IQuickPickSeparator)[] = [];
450455

@@ -455,14 +460,14 @@ export async function showConfigureHooksQuickPick(
455460
alwaysShow: true
456461
});
457462

458-
// Add existing hooks
459-
if (hooksOfType.length > 0) {
463+
// Add existing file-based hooks
464+
if (fileHooks.length > 0) {
460465
hookItems.push({
461466
type: 'separator',
462467
label: localize('existingHooks', "Existing Hooks")
463468
});
464469

465-
for (const entry of hooksOfType) {
470+
for (const entry of fileHooks) {
466471
const description = labelService.getUriLabel(entry.fileUri, { relative: true });
467472
hookItems.push({
468473
label: entry.commandLabel,
@@ -472,6 +477,26 @@ export async function showConfigureHooksQuickPick(
472477
}
473478
}
474479

480+
// Add agent-defined hooks grouped by agent name
481+
if (agentHooks.length > 0) {
482+
const agentNames = [...new Set(agentHooks.map(h => h.agentName!))];
483+
for (const agentName of agentNames) {
484+
hookItems.push({
485+
type: 'separator',
486+
label: localize('agentHooks', "Agent: {0}", agentName)
487+
});
488+
489+
for (const entry of agentHooks.filter(h => h.agentName === agentName)) {
490+
const description = labelService.getUriLabel(entry.fileUri, { relative: true });
491+
hookItems.push({
492+
label: entry.commandLabel,
493+
description,
494+
hookEntry: entry
495+
});
496+
}
497+
}
498+
}
499+
475500
// Auto-execute if only "Add new hook" is available (no existing hooks)
476501
if (hooksOfType.length === 0) {
477502
selectedHook = hookItems[0] as IHookQuickPickItem;
@@ -500,22 +525,34 @@ export async function showConfigureHooksQuickPick(
500525
const entry = selectedHook.hookEntry;
501526
let selection: ITextEditorSelection | undefined;
502527

503-
// Determine the command field name to highlight based on target platform
504-
const commandFieldName = getEffectiveCommandFieldKey(entry.command, targetOS);
505-
506-
// Try to find the command field to highlight
507-
if (commandFieldName) {
528+
if (entry.agentName) {
529+
// Agent hook: search the YAML frontmatter for the command
508530
try {
509531
const content = await fileService.readFile(entry.fileUri);
510-
selection = findHookCommandSelection(
511-
content.value.toString(),
512-
entry.originalHookTypeId,
513-
entry.index,
514-
commandFieldName
515-
);
532+
const commandText = formatHookCommandLabel(entry.command, targetOS);
533+
if (commandText) {
534+
selection = findHookCommandInYaml(content.value.toString(), commandText);
535+
}
516536
} catch {
517537
// Ignore errors and just open without selection
518538
}
539+
} else {
540+
// File hook: use JSON-based selection finder
541+
const commandFieldName = getEffectiveCommandFieldKey(entry.command, targetOS);
542+
543+
if (commandFieldName) {
544+
try {
545+
const content = await fileService.readFile(entry.fileUri);
546+
selection = findHookCommandSelection(
547+
content.value.toString(),
548+
entry.originalHookTypeId,
549+
entry.index,
550+
commandFieldName
551+
);
552+
} catch {
553+
// Ignore errors and just open without selection
554+
}
555+
}
519556
}
520557

521558
if (options?.openEditor) {

src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,54 @@ export function findHookCommandSelection(content: string, hookType: string, inde
114114
};
115115
}
116116

117+
/**
118+
* Finds the selection range for a hook command string in a YAML/Markdown file
119+
* (e.g., an agent `.md` file with YAML frontmatter).
120+
*
121+
* Searches for the command text within command field lines and selects the value.
122+
* Supports all hook command field keys: command, windows, linux, osx, bash, powershell.
123+
*
124+
* @param content The full file content
125+
* @param commandText The command string to locate
126+
* @returns The selection range, or undefined if not found
127+
*/
128+
export function findHookCommandInYaml(content: string, commandText: string): ITextEditorSelection | undefined {
129+
const commandFieldKeys = ['command', 'windows', 'linux', 'osx', 'bash', 'powershell'];
130+
const lines = content.split('\n');
131+
for (let i = 0; i < lines.length; i++) {
132+
const line = lines[i];
133+
const trimmed = line.trimStart();
134+
135+
// Only match lines whose YAML key is a known command field
136+
const matchedKey = commandFieldKeys.find(key =>
137+
trimmed.startsWith(`${key}:`) || trimmed.startsWith(`- ${key}:`)
138+
);
139+
if (!matchedKey) {
140+
continue;
141+
}
142+
143+
// Search after the colon to avoid matching within the key name itself
144+
const colonIdx = line.indexOf(':');
145+
const idx = line.indexOf(commandText, colonIdx + 1);
146+
if (idx !== -1) {
147+
// Verify this is a full match (not a substring of a longer command)
148+
const afterIdx = idx + commandText.length;
149+
const charAfter = afterIdx < line.length ? line.charCodeAt(afterIdx) : -1;
150+
// Accept if what follows is end of line, a quote, or whitespace
151+
if (charAfter === -1 || charAfter === 34 /* " */ || charAfter === 39 /* ' */ || charAfter === 32 /* space */ || charAfter === 9 /* tab */) {
152+
return {
153+
startLineNumber: i + 1,
154+
startColumn: idx + 1,
155+
endLineNumber: i + 1,
156+
endColumn: idx + 1 + commandText.length
157+
};
158+
}
159+
}
160+
}
161+
162+
return undefined;
163+
}
164+
117165
/**
118166
* Parsed hook information.
119167
*/
@@ -129,11 +177,15 @@ export interface IParsedHook {
129177
originalHookTypeId: string;
130178
/** If true, this hook is disabled via `disableAllHooks: true` in its file */
131179
disabled?: boolean;
180+
/** If set, this hook came from a custom agent's frontmatter */
181+
agentName?: string;
132182
}
133183

134184
export interface IParseAllHookFilesOptions {
135185
/** Additional file URIs to parse (e.g., files skipped due to disableAllHooks) */
136186
additionalDisabledFileUris?: readonly URI[];
187+
/** If true, also collect hooks from custom agent frontmatter */
188+
includeAgentHooks?: boolean;
137189
}
138190

139191
/**
@@ -227,5 +279,40 @@ export async function parseAllHookFiles(
227279
}
228280
}
229281

282+
// Collect hooks from custom agents' frontmatter
283+
if (options?.includeAgentHooks) {
284+
const agents = await promptsService.getCustomAgents(token);
285+
for (const agent of agents) {
286+
if (!agent.hooks) {
287+
continue;
288+
}
289+
for (const hookTypeValue of Object.values(HookType)) {
290+
const commands = agent.hooks[hookTypeValue];
291+
if (!commands || commands.length === 0) {
292+
continue;
293+
}
294+
const hookTypeMeta = HOOK_METADATA[hookTypeValue];
295+
if (!hookTypeMeta) {
296+
continue;
297+
}
298+
for (let i = 0; i < commands.length; i++) {
299+
const command = commands[i];
300+
const commandLabel = formatHookCommandLabel(command, os) || nls.localize('commands.hook.emptyCommand', '(empty command)');
301+
parsedHooks.push({
302+
hookType: hookTypeValue,
303+
hookTypeLabel: hookTypeMeta.label,
304+
command,
305+
commandLabel,
306+
fileUri: agent.uri,
307+
filePath: labelService.getUriLabel(agent.uri, { relative: true }),
308+
index: i,
309+
originalHookTypeId: hookTypeValue,
310+
agentName: agent.name,
311+
});
312+
}
313+
}
314+
}
315+
}
316+
230317
return parsedHooks;
231318
}

src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ import { ChatMessageRole, IChatMessage, ILanguageModelsService } from '../langua
5353
import { ILanguageModelToolsService } from '../tools/languageModelToolsService.js';
5454
import { ChatSessionOperationLog } from '../model/chatSessionOperationLog.js';
5555
import { IPromptsService } from '../promptSyntax/service/promptsService.js';
56-
import { ChatRequestHooks } from '../promptSyntax/hookSchema.js';
56+
import { ChatRequestHooks, mergeHooks } from '../promptSyntax/hookSchema.js';
5757

5858
const serializedChatKey = 'interactive.sessions';
5959

@@ -959,6 +959,20 @@ export class ChatService extends Disposable implements IChatService {
959959
this.logService.warn('[ChatService] Failed to collect hooks:', error);
960960
}
961961

962+
// Merge hooks from the selected custom agent's frontmatter (if any)
963+
const agentName = options?.modeInfo?.modeInstructions?.name;
964+
if (agentName) {
965+
try {
966+
const agents = await this.promptsService.getCustomAgents(token, model.sessionResource);
967+
const customAgent = agents.find(a => a.name === agentName);
968+
if (customAgent?.hooks) {
969+
collectedHooks = mergeHooks(collectedHooks, customAgent.hooks);
970+
}
971+
} catch (error) {
972+
this.logService.warn('[ChatService] Failed to collect agent hooks:', error);
973+
}
974+
}
975+
962976
const stopWatch = new StopWatch(false);
963977
store.add(token.onCancellationRequested(() => {
964978
this.trace('sendRequest', `Request for session ${model.sessionResource} was cancelled`);

0 commit comments

Comments
 (0)