Path: blob/main/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts
5272 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { CancellationToken } from '../../../../../base/common/cancellation.js';6import { match, splitGlobAware } from '../../../../../base/common/glob.js';7import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js';8import { Schemas } from '../../../../../base/common/network.js';9import { basename, dirname } from '../../../../../base/common/resources.js';10import { URI } from '../../../../../base/common/uri.js';11import { localize } from '../../../../../nls.js';12import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';13import { IFileService } from '../../../../../platform/files/common/files.js';14import { ILabelService } from '../../../../../platform/label/common/label.js';15import { ILogService } from '../../../../../platform/log/common/log.js';16import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js';17import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js';18import { ChatRequestVariableSet, IChatRequestVariableEntry, isPromptFileVariableEntry, toPromptFileVariableEntry, toPromptTextVariableEntry, PromptFileVariableKind, IPromptTextVariableEntry, ChatRequestToolReferenceEntry, toToolVariableEntry } from '../attachments/chatVariableEntries.js';19import { ILanguageModelToolsService, IToolData, VSCodeToolReference } from '../tools/languageModelToolsService.js';20import { PromptsConfig } from './config/config.js';21import { isInClaudeRulesFolder, isPromptOrInstructionsFile } from './config/promptFileLocations.js';22import { PromptsType } from './promptTypes.js';23import { ParsedPromptFile } from './promptFileParser.js';24import { AgentFileType, ICustomAgent, IPromptPath, IPromptsService } from './service/promptsService.js';25import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js';26import { ChatConfiguration, ChatModeKind } from '../constants.js';27import { UserSelectedTools } from '../participants/chatAgents.js';2829export type InstructionsCollectionEvent = {30applyingInstructionsCount: number;31referencedInstructionsCount: number;32agentInstructionsCount: number;33listedInstructionsCount: number;34totalInstructionsCount: number;35};36export function newInstructionsCollectionEvent(): InstructionsCollectionEvent {37return { applyingInstructionsCount: 0, referencedInstructionsCount: 0, agentInstructionsCount: 0, listedInstructionsCount: 0, totalInstructionsCount: 0 };38}3940type InstructionsCollectionClassification = {41applyingInstructionsCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of instructions added via pattern matching.' };42referencedInstructionsCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of instructions added via references from other instruction files.' };43agentInstructionsCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of agent instructions added (copilot-instructions.md and agents.md).' };44listedInstructionsCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of instruction patterns added.' };45totalInstructionsCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of instruction entries added to variables.' };46owner: 'digitarald';47comment: 'Tracks automatic instruction collection usage in chat prompt system.';48};4950export class ComputeAutomaticInstructions {5152private _parseResults: ResourceMap<ParsedPromptFile> = new ResourceMap();5354constructor(55private readonly _modeKind: ChatModeKind,56private readonly _enabledTools: UserSelectedTools | undefined,57private readonly _enabledSubagents: (readonly string[]) | undefined,58@IPromptsService private readonly _promptsService: IPromptsService,59@ILogService public readonly _logService: ILogService,60@ILabelService private readonly _labelService: ILabelService,61@IConfigurationService private readonly _configurationService: IConfigurationService,62@IWorkspaceContextService private readonly _workspaceService: IWorkspaceContextService,63@IFileService private readonly _fileService: IFileService,64@ITelemetryService private readonly _telemetryService: ITelemetryService,65@ILanguageModelToolsService private readonly _languageModelToolsService: ILanguageModelToolsService,66) {67}6869private async _parseInstructionsFile(uri: URI, token: CancellationToken): Promise<ParsedPromptFile | undefined> {70if (this._parseResults.has(uri)) {71return this._parseResults.get(uri)!;72}73try {74const result = await this._promptsService.parseNew(uri, token);75this._parseResults.set(uri, result);76return result;77} catch (error) {78this._logService.error(`[InstructionsContextComputer] Failed to parse instruction file: ${uri}`, error);79return undefined;80}8182}8384public async collect(variables: ChatRequestVariableSet, token: CancellationToken): Promise<void> {8586const instructionFiles = await this._promptsService.listPromptFiles(PromptsType.instructions, token);8788this._logService.trace(`[InstructionsContextComputer] ${instructionFiles.length} instruction files available.`);8990const telemetryEvent: InstructionsCollectionEvent = newInstructionsCollectionEvent();91const context = this._getContext(variables);9293// find instructions where the `applyTo` matches the attached context94await this.addApplyingInstructions(instructionFiles, context, variables, telemetryEvent, token);9596// add all instructions referenced by all instruction files that are in the context97await this._addReferencedInstructions(variables, telemetryEvent, token);9899// get copilot instructions100await this._addAgentInstructions(variables, telemetryEvent, token);101102const instructionsListVariable = await this._getInstructionsWithPatternsList(instructionFiles, variables, token);103if (instructionsListVariable) {104variables.add(instructionsListVariable);105telemetryEvent.listedInstructionsCount++;106}107108this.sendTelemetry(telemetryEvent);109}110111private sendTelemetry(telemetryEvent: InstructionsCollectionEvent): void {112// Emit telemetry113telemetryEvent.totalInstructionsCount = telemetryEvent.agentInstructionsCount + telemetryEvent.referencedInstructionsCount + telemetryEvent.applyingInstructionsCount + telemetryEvent.listedInstructionsCount;114this._telemetryService.publicLog2<InstructionsCollectionEvent, InstructionsCollectionClassification>('instructionsCollected', telemetryEvent);115}116117/** public for testing */118public async addApplyingInstructions(instructionFiles: readonly IPromptPath[], context: { files: ResourceSet; instructions: ResourceSet }, variables: ChatRequestVariableSet, telemetryEvent: InstructionsCollectionEvent, token: CancellationToken): Promise<void> {119const includeApplyingInstructions = this._configurationService.getValue(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS);120if (!includeApplyingInstructions && this._modeKind !== ChatModeKind.Edit) {121this._logService.trace(`[InstructionsContextComputer] includeApplyingInstructions is disabled and agent kind is not Edit. No applying instructions will be added.`);122return;123}124125for (const { uri } of instructionFiles) {126const parsedFile = await this._parseInstructionsFile(uri, token);127if (!parsedFile) {128this._logService.trace(`[InstructionsContextComputer] Unable to read: ${uri}`);129continue;130}131132const applyTo = parsedFile.header?.applyTo;133const paths = parsedFile.header?.paths;134135// Claude rules files use `paths` (defaulting to '**' when omitted),136// regular instruction files use `applyTo` (skipped when omitted)137const isClaudeRules = isInClaudeRulesFolder(uri);138const pattern = isClaudeRules ? (paths?.join(', ') ?? '**') : applyTo;139140if (!pattern) {141this._logService.trace(`[InstructionsContextComputer] No 'applyTo' found: ${uri}`);142continue;143}144145if (context.instructions.has(uri)) {146// the instruction file is already part of the input or has already been processed147this._logService.trace(`[InstructionsContextComputer] Skipping already processed instruction file: ${uri}`);148continue;149}150151const match = this._matches(context.files, pattern);152if (match) {153this._logService.trace(`[InstructionsContextComputer] Match for ${uri} with ${match.pattern}${match.file ? ` for file ${match.file}` : ''}`);154155const reason = !match.file ?156localize('instruction.file.reason.allFiles', 'Automatically attached as pattern is **') :157localize('instruction.file.reason.specificFile', 'Automatically attached as pattern {0} matches {1}', pattern, this._labelService.getUriLabel(match.file, { relative: true }));158159variables.add(toPromptFileVariableEntry(uri, PromptFileVariableKind.Instruction, reason, true));160telemetryEvent.applyingInstructionsCount++;161} else {162this._logService.trace(`[InstructionsContextComputer] No match for ${uri} with ${pattern}`);163}164}165}166167private _getContext(attachedContext: ChatRequestVariableSet): { files: ResourceSet; instructions: ResourceSet } {168const files = new ResourceSet();169const instructions = new ResourceSet();170for (const variable of attachedContext.asArray()) {171if (isPromptFileVariableEntry(variable)) {172instructions.add(variable.value);173} else {174const uri = IChatRequestVariableEntry.toUri(variable);175if (uri) {176files.add(uri);177}178}179}180181return { files, instructions };182}183184private async _addAgentInstructions(variables: ChatRequestVariableSet, telemetryEvent: InstructionsCollectionEvent, token: CancellationToken): Promise<void> {185const logger = {186logInfo: (message: string) => this._logService.trace(`[InstructionsContextComputer] ${message}`)187};188const allCandidates = await this._promptsService.listAgentInstructions(token, logger);189190const entries: ChatRequestVariableSet = new ChatRequestVariableSet();191const copilotEntries: ChatRequestVariableSet = new ChatRequestVariableSet();192193for (const { uri, type } of allCandidates) {194const varEntry = toPromptFileVariableEntry(uri, PromptFileVariableKind.Instruction, undefined, true);195entries.add(varEntry);196if (type === AgentFileType.copilotInstructionsMd) {197copilotEntries.add(varEntry);198}199200telemetryEvent.agentInstructionsCount++;201logger.logInfo(`Agent instruction file added: ${uri.toString()}`);202}203204// Process referenced instructions from copilot files (maintaining original behavior)205if (copilotEntries.length > 0) {206await this._addReferencedInstructions(copilotEntries, telemetryEvent, token);207for (const entry of copilotEntries.asArray()) {208variables.add(entry);209}210}211212for (const entry of entries.asArray()) {213variables.add(entry);214}215}216217/**218* Combines the `applyTo` and `paths` attributes into a single comma-separated219* pattern string that can be matched by {@link _matches}.220* Used for the instructions list XML output where both should be shown.221*/222private _getApplyToPattern(applyTo: string | undefined, paths: readonly string[] | undefined): string | undefined {223if (applyTo) {224return applyTo;225}226if (paths && paths.length > 0) {227return paths.join(', ');228}229return undefined;230}231232private _matches(files: ResourceSet, applyToPattern: string): { pattern: string; file?: URI } | undefined {233const patterns = splitGlobAware(applyToPattern, ',');234const patterMatches = (pattern: string): { pattern: string; file?: URI } | undefined => {235pattern = pattern.trim();236if (pattern.length === 0) {237// if glob pattern is empty, skip it238return undefined;239}240if (pattern === '**' || pattern === '**/*' || pattern === '*') {241// if glob pattern is one of the special wildcard values,242// add the instructions file event if no files are attached243return { pattern };244}245if (!pattern.startsWith('/') && !pattern.startsWith('**/')) {246// support relative glob patterns, e.g. `src/**/*.js`247pattern = '**/' + pattern;248}249250// match each attached file with each glob pattern and251// add the instructions file if its rule matches the file252for (const file of files) {253// if the file is not a valid URI, skip it254if (match(pattern, file.path, { ignoreCase: true })) {255return { pattern, file }; // return the matched pattern and file URI256}257}258return undefined;259};260for (const pattern of patterns) {261const matchResult = patterMatches(pattern);262if (matchResult) {263return matchResult; // return the first matched pattern and file URI264}265}266return undefined;267}268269private _getTool(referenceName: string): { tool: IToolData; variable: string } | undefined {270if (!this._enabledTools) {271return undefined;272}273const tool = this._languageModelToolsService.getToolByName(referenceName);274if (tool && this._enabledTools[tool.id]) {275return { tool, variable: `#tool:${this._languageModelToolsService.getFullReferenceName(tool)}` };276}277return undefined;278}279280private async _getInstructionsWithPatternsList(instructionFiles: readonly IPromptPath[], _existingVariables: ChatRequestVariableSet, token: CancellationToken): Promise<IPromptTextVariableEntry | undefined> {281const readTool = this._getTool('readFile');282const runSubagentTool = this._getTool(VSCodeToolReference.runSubagent);283284const entries: string[] = [];285if (readTool) {286287const searchNestedAgentMd = this._configurationService.getValue(PromptsConfig.USE_NESTED_AGENT_MD);288const agentsMdPromise = searchNestedAgentMd ? this._promptsService.listNestedAgentMDs(token) : Promise.resolve([]);289290entries.push('<instructions>');291entries.push('Here is a list of instruction files that contain rules for working with this codebase.');292entries.push('These files are important for understanding the codebase structure, conventions, and best practices.');293entries.push('Please make sure to follow the rules specified in these files when working with the codebase.');294entries.push(`If the file is not already available as attachment, use the ${readTool.variable} tool to acquire it.`);295entries.push('Make sure to acquire the instructions before working with the codebase.');296let hasContent = false;297for (const { uri } of instructionFiles) {298const parsedFile = await this._parseInstructionsFile(uri, token);299if (parsedFile) {300entries.push('<instruction>');301if (parsedFile.header) {302const { description, applyTo, paths } = parsedFile.header;303if (description) {304entries.push(`<description>${description}</description>`);305}306entries.push(`<file>${getFilePath(uri)}</file>`);307const applyToPattern = this._getApplyToPattern(applyTo, paths);308if (applyToPattern) {309entries.push(`<applyTo>${applyToPattern}</applyTo>`);310}311} else {312entries.push(`<file>${getFilePath(uri)}</file>`);313}314entries.push('</instruction>');315hasContent = true;316}317}318319const agentsMdFiles = await agentsMdPromise;320for (const { uri } of agentsMdFiles) {321const folderName = this._labelService.getUriLabel(dirname(uri), { relative: true });322const description = folderName.trim().length === 0 ? localize('instruction.file.description.agentsmd.root', 'Instructions for the workspace') : localize('instruction.file.description.agentsmd.folder', 'Instructions for folder \'{0}\'', folderName);323entries.push('<instruction>');324entries.push(`<description>${description}</description>`);325entries.push(`<file>${getFilePath(uri)}</file>`);326entries.push('</instruction>');327hasContent = true;328329}330331if (!hasContent) {332entries.length = 0; // clear entries333} else {334entries.push('</instructions>', '', ''); // add trailing newline335}336337const agentSkills = await this._promptsService.findAgentSkills(token);338// Filter out skills with disableModelInvocation=true (they can only be triggered manually via /name)339const modelInvokableSkills = agentSkills?.filter(skill => !skill.disableModelInvocation);340if (modelInvokableSkills && modelInvokableSkills.length > 0) {341const useSkillAdherencePrompt = this._configurationService.getValue(PromptsConfig.USE_SKILL_ADHERENCE_PROMPT);342entries.push('<skills>');343if (useSkillAdherencePrompt) {344// Stronger skill adherence prompt for experimental feature345entries.push('Skills provide specialized capabilities, domain knowledge, and refined workflows for producing high-quality outputs. Each skill folder contains tested instructions for specific domains like testing strategies, API design, or performance optimization. Multiple skills can be combined when a task spans different domains.');346entries.push(`BLOCKING REQUIREMENT: When a skill applies to the user's request, you MUST load and read the SKILL.md file IMMEDIATELY as your first action, BEFORE generating any other response or taking action on the task. Use ${readTool.variable} to load the relevant skill(s).`);347entries.push('NEVER just mention or reference a skill in your response without actually reading it first. If a skill is relevant, load it before proceeding.');348entries.push('How to determine if a skill applies:');349entries.push('1. Review the available skills below and match their descriptions against the user\'s request');350entries.push('2. If any skill\'s domain overlaps with the task, load that skill immediately');351entries.push('3. When multiple skills apply (e.g., a flowchart in documentation), load all relevant skills');352entries.push('Examples:');353entries.push(`- "Help me write unit tests for this module" -> Load the testing skill via ${readTool.variable} FIRST, then proceed`);354entries.push(`- "Optimize this slow function" -> Load the performance-profiling skill via ${readTool.variable} FIRST, then proceed`);355entries.push(`- "Add a discount code field to checkout" -> Load both the checkout-flow and form-validation skills FIRST`);356entries.push('Available skills:');357} else {358entries.push('Here is a list of skills that contain domain specific knowledge on a variety of topics.');359entries.push('Each skill comes with a description of the topic and a file path that contains the detailed instructions.');360entries.push(`When a user asks you to perform a task that falls within the domain of a skill, use the ${readTool.variable} tool to acquire the full instructions from the file URI.`);361}362for (const skill of modelInvokableSkills) {363entries.push('<skill>');364entries.push(`<name>${skill.name}</name>`);365if (skill.description) {366entries.push(`<description>${skill.description}</description>`);367}368entries.push(`<file>${getFilePath(skill.uri)}</file>`);369entries.push('</skill>');370}371entries.push('</skills>', '', ''); // add trailing newline372}373}374if (runSubagentTool && this._configurationService.getValue(ChatConfiguration.SubagentToolCustomAgents)) {375const canUseAgent = (() => {376if (!this._enabledSubagents || this._enabledSubagents.includes('*')) {377return (agent: ICustomAgent) => agent.visibility.agentInvokable;378} else {379const subagents = this._enabledSubagents;380return (agent: ICustomAgent) => subagents.includes(agent.name);381}382})();383const agents = await this._promptsService.getCustomAgents(token);384if (agents.length > 0) {385entries.push('<agents>');386entries.push('Here is a list of agents that can be used when running a subagent.');387entries.push('Each agent has optionally a description with the agent\'s purpose and expertise. When asked to run a subagent, choose the most appropriate agent from this list.');388entries.push(`Use the ${runSubagentTool.variable} tool with the agent name to run the subagent.`);389for (const agent of agents) {390if (canUseAgent(agent)) {391entries.push('<agent>');392entries.push(`<name>${agent.name}</name>`);393if (agent.description) {394entries.push(`<description>${agent.description}</description>`);395}396if (agent.argumentHint) {397entries.push(`<argumentHint>${agent.argumentHint}</argumentHint>`);398}399entries.push('</agent>');400}401}402entries.push('</agents>', '', ''); // add trailing newline403}404}405if (entries.length === 0) {406return undefined;407}408409const content = entries.join('\n');410const toolReferences: ChatRequestToolReferenceEntry[] = [];411const collectToolReference = (tool: { tool: IToolData; variable: string } | undefined) => {412if (tool) {413let offset = content.indexOf(tool.variable);414while (offset >= 0) {415toolReferences.push(toToolVariableEntry(tool.tool, new OffsetRange(offset, offset + tool.variable.length)));416offset = content.indexOf(tool.variable, offset + 1);417}418}419};420collectToolReference(readTool);421collectToolReference(runSubagentTool);422return toPromptTextVariableEntry(content, true, toolReferences);423}424425private async _addReferencedInstructions(attachedContext: ChatRequestVariableSet, telemetryEvent: InstructionsCollectionEvent, token: CancellationToken): Promise<void> {426const includeReferencedInstructions = this._configurationService.getValue(PromptsConfig.INCLUDE_REFERENCED_INSTRUCTIONS);427if (!includeReferencedInstructions && this._modeKind !== ChatModeKind.Edit) {428this._logService.trace(`[InstructionsContextComputer] includeReferencedInstructions is disabled and agent kind is not Edit. No referenced instructions will be added.`);429return;430}431432const seen = new ResourceSet();433const todo: URI[] = [];434for (const variable of attachedContext.asArray()) {435if (isPromptFileVariableEntry(variable)) {436if (!seen.has(variable.value)) {437todo.push(variable.value);438seen.add(variable.value);439}440}441}442let next = todo.pop();443while (next) {444const result = await this._parseInstructionsFile(next, token);445if (result && result.body) {446const refsToCheck: { resource: URI }[] = [];447for (const ref of result.body.fileReferences) {448const url = result.body.resolveFilePath(ref.content);449if (url && !seen.has(url) && (isPromptOrInstructionsFile(url) || this._workspaceService.getWorkspaceFolder(url) !== undefined)) {450// only add references that are either prompt or instruction files or are part of the workspace451refsToCheck.push({ resource: url });452seen.add(url);453}454}455if (refsToCheck.length > 0) {456const stats = await this._fileService.resolveAll(refsToCheck);457for (let i = 0; i < stats.length; i++) {458const stat = stats[i];459const uri = refsToCheck[i].resource;460if (stat.success && stat.stat?.isFile) {461if (isPromptOrInstructionsFile(uri)) {462// only recursively parse instruction files463todo.push(uri);464}465const reason = localize('instruction.file.reason.referenced', 'Referenced by {0}', basename(next));466attachedContext.add(toPromptFileVariableEntry(uri, PromptFileVariableKind.InstructionReference, reason, true));467telemetryEvent.referencedInstructionsCount++;468this._logService.trace(`[InstructionsContextComputer] ${uri.toString()} added, referenced by ${next.toString()}`);469}470}471}472}473next = todo.pop();474}475}476}477478479function getFilePath(uri: URI): string {480if (uri.scheme === Schemas.file || uri.scheme === Schemas.vscodeRemote) {481return uri.fsPath;482}483return uri.toString();484}485486487