Enable custom agent hooks by default (#311827)

This commit is contained in:
Paul
2026-04-22 11:48:28 -07:00
committed by GitHub
parent 31320b4c22
commit 6baf32905c
11 changed files with 44 additions and 50 deletions

View File

@@ -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

View File

@@ -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
- **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`.

View File

@@ -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,

View File

@@ -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).
*/

View File

@@ -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<boolean>(PromptsConfig.USE_CUSTOM_AGENT_HOOKS)) {
attributesToPropose.delete(PromptHeaderAttributes.hooks);
}
for (const attr of header.attributes) {
attributesToPropose.delete(attr.key);
}

View File

@@ -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<boolean>(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);

View File

@@ -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<boolean>(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<boolean>(PromptsConfig.USE_CUSTOM_AGENT_HOOKS)) {
validAttributeNames = validAttributeNames.filter(name => name !== PromptHeaderAttributes.hooks);
}
const useCustomAgentHooks = this.configurationService.getValue<boolean>(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) {

View File

@@ -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<boolean>(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);

View File

@@ -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

View File

@@ -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

View File

@@ -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