From 6baf32905c4b586fe5e31966aea14322ed5b8fb8 Mon Sep 17 00:00:00 2001 From: Paul Date: Wed, 22 Apr 2026 11:48:28 -0700 Subject: [PATCH] Enable custom agent hooks by default (#311827) --- .../skills/agent-customization/SKILL.md | 2 +- .../agent-customization/references/agents.md | 36 ++++++++++++++++++- .../contrib/chat/browser/chat.contribution.ts | 9 ----- .../chat/common/promptSyntax/config/config.ts | 5 --- .../promptHeaderAutocompletion.ts | 6 ---- .../languageProviders/promptHovers.ts | 6 ---- .../languageProviders/promptValidator.ts | 18 ++-------- .../service/promptsServiceImpl.ts | 8 +++-- .../promptHeaderAutocompletion.test.ts | 1 - .../languageProviders/promptHovers.test.ts | 1 - .../languageProviders/promptValidator.test.ts | 2 -- 11 files changed, 44 insertions(+), 50 deletions(-) diff --git a/extensions/copilot/assets/prompts/skills/agent-customization/SKILL.md b/extensions/copilot/assets/prompts/skills/agent-customization/SKILL.md index f1f0179aa5f..6c2dd839f36 100644 --- a/extensions/copilot/assets/prompts/skills/agent-customization/SKILL.md +++ b/extensions/copilot/assets/prompts/skills/agent-customization/SKILL.md @@ -72,7 +72,7 @@ After creating: **Skill vs Custom Agent?** Same capabilities for all steps → Skill. Need context isolation (subagent returns single output) or different tool restrictions per stage → Custom Agent. -**Hooks vs Instructions?** Instructions *guide* agent behavior (non-deterministic). Hooks *enforce* behavior via shell commands at lifecycle events like `PreToolUse` or `PostToolUse` — they can block operations, require approval, or run formatters deterministically. See [hooks reference](./references/hooks.md). +**Hooks vs Instructions?** Instructions *guide* agent behavior (non-deterministic). Hooks *enforce* behavior via shell commands at lifecycle events like `PreToolUse` or `PostToolUse` — they can block operations, require approval, or run formatters deterministically. Hooks can be defined in standalone `.json` files (see [hooks reference](./references/hooks.md)) or inline in custom agent frontmatter via the `hooks` attribute (see [agents reference](./references/agents.md)). ## Common Pitfalls diff --git a/extensions/copilot/assets/prompts/skills/agent-customization/references/agents.md b/extensions/copilot/assets/prompts/skills/agent-customization/references/agents.md index 3721b3b6922..586419deb46 100644 --- a/extensions/copilot/assets/prompts/skills/agent-customization/references/agents.md +++ b/extensions/copilot/assets/prompts/skills/agent-customization/references/agents.md @@ -22,6 +22,13 @@ agents: [agent1, agent2] # Optional, restrict allowed subagents by name (omi user-invocable: true # Optional, show in agent picker (default: true) disable-model-invocation: false # Optional, prevent subagent invocation (default: false) handoffs: [...] # Optional, transitions to other agents +hooks: # Optional, inline hooks for this agent's lifecycle events + PreToolUse: + - type: command + command: "./scripts/validate.sh" + PostToolUse: + - type: command + command: "./scripts/format.sh" --- ``` @@ -108,4 +115,31 @@ You are a specialist at {specific task}. Your job is to {clear purpose}. - **Swiss-army agents**: Too many tools, tries to do everything - **Vague descriptions**: "A helpful agent" doesn't guide delegation—be specific - **Role confusion**: Description doesn't match body persona -- **Circular handoffs**: A → B → A without progress criteria \ No newline at end of file +- **Circular handoffs**: A → B → A without progress criteria + +## Inline Hooks + +Custom agents support inline `hooks` in frontmatter. These hooks execute shell commands at agent lifecycle points and are scoped to this agent only. The format matches standalone hook files (see [hooks reference](../hooks.md)). + +### Supported Events + +`SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PreCompact`, `SubagentStart`, `SubagentStop`, `Stop` + +### Example + +```yaml +--- +description: "Secure code reviewer that blocks dangerous commands" +tools: [read, search, execute] +hooks: + PreToolUse: + - type: command + command: "./scripts/block-dangerous-cmds.sh" + timeout: 10 + PostToolUse: + - type: command + command: "./scripts/auto-lint.sh" +--- +``` + +Each hook command supports: `type` (must be `command`), `command`, platform overrides (`windows`, `linux`, `osx`), `cwd`, `env`, `timeout`. diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 9fe5a1b1309..898b5cf4898 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -1337,15 +1337,6 @@ configurationRegistry.registerConfiguration({ disallowConfigurationDefault: true, tags: ['preview', 'prompts', 'hooks', 'agent'] }, - [PromptsConfig.USE_CUSTOM_AGENT_HOOKS]: { - type: 'boolean', - title: nls.localize('chat.useCustomAgentHooks.title', "Use Custom Agent Hooks",), - markdownDescription: nls.localize('chat.useCustomAgentHooks.description', "Controls whether hooks defined in custom agent frontmatter are parsed and executed. When disabled, hooks from agent files are ignored.",), - default: false, - restricted: true, - disallowConfigurationDefault: true, - tags: ['preview', 'prompts', 'hooks', 'agent'] - }, [PromptsConfig.PROMPT_FILES_SUGGEST_KEY]: { type: 'object', scope: ConfigurationScope.RESOURCE, diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts index 71798f5bbd6..ea442155106 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts @@ -115,11 +115,6 @@ export namespace PromptsConfig { */ export const USE_CLAUDE_HOOKS = 'chat.useClaudeHooks'; - /** - * Configuration key for enabling hooks defined in custom agent frontmatter. - */ - export const USE_CUSTOM_AGENT_HOOKS = 'chat.useCustomAgentHooks'; - /** * Configuration key for enabling stronger skill adherence prompt (experimental). */ diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts index de33bb91fd0..e277a9eebc3 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts @@ -22,8 +22,6 @@ import { formatArrayValue, getQuotePreference } from '../utils/promptEditHelper. import { HOOKS_BY_TARGET, HOOK_METADATA } from '../hookTypes.js'; import { HOOK_COMMAND_FIELD_DESCRIPTIONS } from '../hookSchema.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { PromptsConfig } from '../config/config.js'; export class PromptHeaderAutocompletion implements CompletionItemProvider { /** @@ -42,7 +40,6 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { @ILanguageModelToolsService private readonly languageModelToolsService: ILanguageModelToolsService, @IChatModeService private readonly chatModeService: IChatModeService, @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, - @IConfigurationService private readonly configurationService: IConfigurationService, ) { } @@ -143,9 +140,6 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { const target = getTarget(promptType, header); const attributesToPropose = new Set(getValidAttributeNames(promptType, false, target)); - if (!this.configurationService.getValue(PromptsConfig.USE_CUSTOM_AGENT_HOOKS)) { - attributesToPropose.delete(PromptHeaderAttributes.hooks); - } for (const attr of header.attributes) { attributesToPropose.delete(attr.key); } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts index e3d4b279cd9..00065d1b40c 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts @@ -19,8 +19,6 @@ import { IHeaderAttribute, ISequenceValue, parseCommaSeparatedList, PromptBody, import { ClaudeHeaderAttributes, getAttributeDefinition, getTarget, isVSCodeOrDefaultTarget, knownClaudeModels, knownClaudeTools } from './promptFileAttributes.js'; import { HOOKS_BY_TARGET, HOOK_METADATA } from '../hookTypes.js'; import { HOOK_COMMAND_FIELD_DESCRIPTIONS } from '../hookSchema.js'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { PromptsConfig } from '../config/config.js'; export class PromptHoverProvider implements HoverProvider { /** @@ -33,7 +31,6 @@ export class PromptHoverProvider implements HoverProvider { @ILanguageModelToolsService private readonly languageModelToolsService: ILanguageModelToolsService, @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @IChatModeService private readonly chatModeService: IChatModeService, - @IConfigurationService private readonly configurationService: IConfigurationService, ) { } @@ -92,9 +89,6 @@ export class PromptHoverProvider implements HoverProvider { case PromptHeaderAttributes.handOffs: return this.getHandsOffHover(attribute, position, target); case PromptHeaderAttributes.hooks: - if (!this.configurationService.getValue(PromptsConfig.USE_CUSTOM_AGENT_HOOKS)) { - return undefined; - } return this.getHooksHover(attribute, position, description, target); case PromptHeaderAttributes.infer: return this.createHover(description + '\n\n' + localize('promptHeader.attribute.infer.hover', 'Deprecated: Use `user-invocable` and `disable-model-invocation` instead.'), attribute.range); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts index f2551ecb5b0..e34daa4869c 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts @@ -7,7 +7,6 @@ import { isEmptyPattern, parse, splitGlobAware } from '../../../../../../base/co import { Iterable } from '../../../../../../base/common/iterator.js'; import { Range } from '../../../../../../editor/common/core/range.js'; import { localize } from '../../../../../../nls.js'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IMarkerData, MarkerSeverity, MarkerTag } from '../../../../../../platform/markers/common/markers.js'; import { ChatMode, IChatMode, IChatModeService } from '../../chatModes.js'; import { ChatModeKind } from '../../constants.js'; @@ -24,7 +23,6 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js import { dirname } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { HOOKS_BY_TARGET } from '../hookTypes.js'; -import { PromptsConfig } from '../config/config.js'; import { GithubPromptHeaderAttributes } from './promptFileAttributes.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; @@ -38,7 +36,6 @@ export class PromptValidator { @IFileService private readonly fileService: IFileService, @ILabelService private readonly labelService: ILabelService, @IPromptsService private readonly promptsService: IPromptsService, - @IConfigurationService private readonly configurationService: IConfigurationService, @ILogService private readonly logger: ILogService, ) { } @@ -213,9 +210,7 @@ export class PromptValidator { this.validateUserInvocable(attributes, report); this.validateDisableModelInvocation(attributes, report); this.validateTools(attributes, ChatModeKind.Agent, target, report); - if (this.configurationService.getValue(PromptsConfig.USE_CUSTOM_AGENT_HOOKS)) { - this.validateHooks(attributes, target, report); - } + this.validateHooks(attributes, target, report); if (isVSCodeOrDefaultTarget(target)) { this.validateModel(attributes, ChatModeKind.Agent, report); this.validateHandoffs(attributes, report); @@ -237,19 +232,12 @@ export class PromptValidator { } private checkForInvalidArguments(attributes: IHeaderAttribute[], promptType: PromptsType, target: Target, report: (markers: IMarkerData) => void): void { - let validAttributeNames = getValidAttributeNames(promptType, true, target); - if (!this.configurationService.getValue(PromptsConfig.USE_CUSTOM_AGENT_HOOKS)) { - validAttributeNames = validAttributeNames.filter(name => name !== PromptHeaderAttributes.hooks); - } - const useCustomAgentHooks = this.configurationService.getValue(PromptsConfig.USE_CUSTOM_AGENT_HOOKS); + const validAttributeNames = getValidAttributeNames(promptType, true, target); const validGithubCopilotAttributeNames = new Lazy(() => new Set(getValidAttributeNames(promptType, false, Target.GitHubCopilot))); for (const attribute of attributes) { if (!validAttributeNames.includes(attribute.key)) { const supportedNames = new Lazy(() => { - let names = getValidAttributeNames(promptType, false, target); - if (!useCustomAgentHooks) { - names = names.filter(name => name !== PromptHeaderAttributes.hooks); - } + const names = getValidAttributeNames(promptType, false, target); return names.sort().join(', '); }); switch (promptType) { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 645989eb30a..edb055bbbf5 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -216,9 +216,10 @@ export class PromptsService extends Disposable implements IPromptsService { () => Event.any( this.getFileLocatorEvent(PromptsType.agent), Event.filter(modelChangeEvent, e => e.promptType === PromptsType.agent), + Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(PromptsConfig.USE_CHAT_HOOKS)), this._onDidContributedWhenChange.event, - Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(PromptsConfig.USE_CUSTOM_AGENT_HOOKS)), this._onDidPluginPromptFilesChange.event, + this.workspaceTrustService.onDidChangeTrust, ) )); @@ -772,6 +773,8 @@ export class PromptsService extends Disposable implements IPromptsService { const stopWatch = StopWatch.create(true); const allAgentFiles = await this.listPromptFiles(PromptsType.agent, token); const disabledAgents = this.getDisabledPromptFiles(PromptsType.agent); + const useChatHooks = this.configurationService.getValue(PromptsConfig.USE_CHAT_HOOKS); + const isWorkspaceTrusted = this.workspaceTrustService.isWorkspaceTrusted(); // Get user home for tilde expansion in hook cwd paths const userHomeUri = await this.pathService.userHome(); @@ -846,9 +849,8 @@ export class PromptsService extends Disposable implements IPromptsService { // Parse hooks from the frontmatter if present let hooks: ChatRequestHooks | undefined; - const useCustomAgentHooks = this.configurationService.getValue(PromptsConfig.USE_CUSTOM_AGENT_HOOKS); const hooksRaw = ast.header.hooksRaw; - if (useCustomAgentHooks && hooksRaw) { + if (useChatHooks && isWorkspaceTrusted && hooksRaw) { const hookWorkspaceFolder = this.workspaceService.getWorkspaceFolder(uri) ?? defaultFolder; const workspaceRootUri = hookWorkspaceFolder?.uri; hooks = parseSubagentHooksFromYaml(hooksRaw, workspaceRootUri, userHome, target); diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts index 101e3235aea..4ba131c8950 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts @@ -37,7 +37,6 @@ suite('PromptHeaderAutocompletion', () => { setup(async () => { const testConfigService = new TestConfigurationService(); testConfigService.setUserConfiguration(ChatConfiguration.ExtensionToolsEnabled, true); - testConfigService.setUserConfiguration('chat.useCustomAgentHooks', true); instaService = workbenchInstantiationService({ contextKeyService: () => disposables.add(new ContextKeyService(testConfigService)), configurationService: () => testConfigService diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts index ab2a3b4067c..f90f6d45707 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts @@ -37,7 +37,6 @@ suite('PromptHoverProvider', () => { setup(async () => { const testConfigService = new TestConfigurationService(); testConfigService.setUserConfiguration(ChatConfiguration.ExtensionToolsEnabled, true); - testConfigService.setUserConfiguration('chat.useCustomAgentHooks', true); instaService = workbenchInstantiationService({ contextKeyService: () => disposables.add(new ContextKeyService(testConfigService)), configurationService: () => testConfigService diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts index dbcaa1e948c..d561511eae0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts @@ -28,7 +28,6 @@ import { PromptFileParser } from '../../../../common/promptSyntax/promptFilePars import { ICustomAgent, IPromptsService, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; import { MockChatModeService } from '../../../common/mockChatModeService.js'; import { MockPromptsService } from '../../../common/promptSyntax/service/mockPromptsService.js'; -import { PromptsConfig } from '../../../../common/promptSyntax/config/config.js'; suite('PromptValidator', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -43,7 +42,6 @@ suite('PromptValidator', () => { testConfigService = new TestConfigurationService(); testConfigService.setUserConfiguration(ChatConfiguration.ExtensionToolsEnabled, true); - testConfigService.setUserConfiguration(PromptsConfig.USE_CUSTOM_AGENT_HOOKS, true); instaService = workbenchInstantiationService({ contextKeyService: () => disposables.add(new ContextKeyService(testConfigService)), configurationService: () => testConfigService