Path: blob/main/src/vs/workbench/contrib/chat/browser/promptsDebugContribution.ts
13401 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, CancellationTokenSource } from '../../../../base/common/cancellation.js';6import { Disposable } from '../../../../base/common/lifecycle.js';7import { OS } from '../../../../base/common/platform.js';8import { generateUuid } from '../../../../base/common/uuid.js';9import { localize } from '../../../../nls.js';10import { ILogService } from '../../../../platform/log/common/log.js';11import { IWorkbenchContribution } from '../../../common/contributions.js';12import { IChatDebugCustomizationLogEntry, IChatDebugEventFileListContent, IChatDebugResolvedEventContent, IChatDebugService } from '../common/chatDebugService.js';13import { IChatAgentService } from '../common/participants/chatAgents.js';14import { IChatService } from '../common/chatService/chatService.js';15import { ChatRequestHooks, formatHookCommandLabel } from '../common/promptSyntax/hookSchema.js';16import { HookType } from '../common/promptSyntax/hookTypes.js';17import { PromptsType } from '../common/promptSyntax/promptTypes.js';18import { IHookDiscoveryInfo, type InstructionsCollectionDebugInfo, IPromptDiscoveryInfo, IPromptsService } from '../common/promptSyntax/service/promptsService.js';19import { lastInstructionsCollectionResult } from '../common/promptSyntax/computeAutomaticInstructions.js';2021interface ICustomizationEventData {22readonly debugInfo: InstructionsCollectionDebugInfo;23readonly hooks: ChatRequestHooks | undefined;24}2526/**27* Bridges prompt discovery information to {@link IChatDebugService}.28*/29export class PromptsDebugContribution extends Disposable implements IWorkbenchContribution {3031static readonly ID = 'workbench.contrib.promptsDebug';3233private static readonly MAX_DISCOVERY_DETAILS = 10_000;3435/**36* Maps debug event IDs to their discovery info, so that37* {@link IChatDebugService.resolveEvent} can return rich details.38*/39private readonly _discoveryEventDetails = new Map<string, IPromptDiscoveryInfo>();40private readonly _customizationEventDetails = new Map<string, ICustomizationEventData>();41private readonly _loggedSessions = new Set<string>();4243constructor(44@IPromptsService private readonly promptsService: IPromptsService,45@IChatAgentService chatAgentService: IChatAgentService,46@IChatService chatService: IChatService,47@IChatDebugService chatDebugService: IChatDebugService,48@ILogService logService: ILogService,49) {50super();5152// Clean up logged-session entries when sessions are disposed.53this._register(chatService.onDidDisposeSession(e => {54for (const sessionResource of e.sessionResources) {55this._loggedSessions.delete(sessionResource.toString());56}57}));5859// Forward discovery log events to the debug service.60this._register(chatAgentService.onWillInvokeAgent(async e => {61const sessionKey = e.request.sessionResource.toString();62const isFirstInvocation = !this._loggedSessions.has(sessionKey);63this._loggedSessions.add(sessionKey);6465const sessionResource = e.request.sessionResource;6667if (isFirstInvocation) {68const cts = new CancellationTokenSource();69try {70const discoveryInfos = await Promise.all([PromptsType.agent, PromptsType.instructions, PromptsType.prompt, PromptsType.skill, PromptsType.hook].map(type => this.promptsService.getDiscoveryInfo(type, cts.token)));71for (const discoveryInfo of discoveryInfos) {72const { name, details } = this.getDiscoveryLogEntry(discoveryInfo);73const eventId = generateUuid();7475this._discoveryEventDetails.set(eventId, discoveryInfo);7677// Evict oldest entries when the map exceeds the cap.78if (this._discoveryEventDetails.size > PromptsDebugContribution.MAX_DISCOVERY_DETAILS) {79const first = this._discoveryEventDetails.keys().next().value;80if (first !== undefined) {81this._discoveryEventDetails.delete(first);82}83}8485// Enrich details with file paths so they appear in the event86// payload (e.g. forwarded via onDidReceiveChatDebugEvent to the87// extension's JSONL file logger).88const loaded = discoveryInfo.files89.filter(f => f.status === 'loaded')90.map(f => f.promptPath.name ?? f.promptPath.uri.path.split('/').pop() ?? f.promptPath.uri.toString());91const skipped = discoveryInfo.files.filter(f => f.status === 'skipped').map(f => {92const label = f.promptPath.uri.toString();93return f.skipReason ? `${label} (${f.skipReason})` : label;94});95const folders = discoveryInfo.sourceFolders?.map(sf => sf.uri.path) ?? [];96const parts: string[] = [];97if (details) {98parts.push(details);99}100if (loaded.length > 0) {101parts.push(`loaded: [${truncateList(loaded)}]`);102}103if (skipped.length > 0) {104parts.push(`skipped: [${truncateList(skipped)}]`);105}106if (folders.length > 0) {107parts.push(`folders: [${truncateList(folders)}]`);108}109const newDetails = parts.join(' | ') || undefined;110111chatDebugService.log(112sessionResource,113name,114newDetails,115undefined,116{ id: eventId, category: 'discovery' },117);118}119} catch (error) {120logService.error('Error while logging prompt discovery info to chat debug service', error);121} finally {122cts.dispose();123}124}125126// Log resolved customizations from the last instructions collection.127const lastResult = lastInstructionsCollectionResult;128if (!isFirstInvocation && lastResult) {129const { telemetryEvent: collectionEvent, debugInfo } = lastResult;130// Fetch the cached hook discovery info.131let resolvedHooks: ChatRequestHooks | undefined;132try {133const hookDiscoveryInfo = await this.promptsService.getDiscoveryInfo(PromptsType.hook, CancellationToken.None) as IHookDiscoveryInfo;134resolvedHooks = hookDiscoveryInfo.hooksInfo?.hooks;135} catch (error) {136logService.warn('Error while fetching hooks for customization debug event', error);137}138139const parts: string[] = [];140if (collectionEvent.applyingInstructionsCount > 0) {141parts.push(localize('customizations.applying', '{0} applying', collectionEvent.applyingInstructionsCount));142}143if (collectionEvent.referencedInstructionsCount > 0) {144parts.push(localize('customizations.referenced', '{0} referenced', collectionEvent.referencedInstructionsCount));145}146if (collectionEvent.agentInstructionsCount > 0) {147parts.push(localize('customizations.agent', '{0} agent', collectionEvent.agentInstructionsCount));148}149if (collectionEvent.listedInstructionsCount > 0) {150parts.push(localize('customizations.listed', '{0} listed', collectionEvent.listedInstructionsCount));151}152const durationStr = debugInfo.durationInMillis.toFixed(1);153const summary = parts.length > 0154? localize('customizationsResolved.details', 'Resolved {0} customizations ({1}) in {2}ms', collectionEvent.totalInstructionsCount, parts.join(', '), durationStr)155: localize('customizationsResolved.none', 'No customizations resolved');156const detailSummaries = debugInfo.debugDetails.map(e => {157const detail = e.reason ? `${e.name} — ${e.reason}` : e.name;158return `[${e.category}] ${detail}`;159});160const details = detailSummaries.length > 0161? `${summary} | ${detailSummaries.join(', ')}`162: summary;163164const customizationEventId = generateUuid();165this._customizationEventDetails.set(customizationEventId, { debugInfo, hooks: resolvedHooks });166167// Evict oldest entries when the map exceeds the cap.168if (this._customizationEventDetails.size > PromptsDebugContribution.MAX_DISCOVERY_DETAILS) {169const first = this._customizationEventDetails.keys().next().value;170if (first !== undefined) {171this._customizationEventDetails.delete(first);172}173}174175chatDebugService.log(176sessionResource,177localize('customizationsResolved', 'Resolve Customizations'),178details,179undefined,180{ id: customizationEventId, category: 'customization' },181);182}183}));184// Register a resolve provider so expanding a discovery event185// in the Agent Debug Logs shows the full file list.186this._register(chatDebugService.registerProvider({187provideChatDebugLog: async () => undefined,188resolveChatDebugLogEvent: async (eventId) => {189return this._resolveDiscoveryEvent(eventId) ?? this._resolveCustomizationEvent(eventId);190}191}));192}193194private getDiscoveryLogEntry(discoveryInfo: IPromptDiscoveryInfo): { readonly name: string; readonly details?: string } {195196const durationInMillis = discoveryInfo.durationInMillis.toFixed(1);197const loadedCount = discoveryInfo.files.filter(file => file.status === 'loaded').length;198const skippedCount = discoveryInfo.files.length - loadedCount;199200switch (discoveryInfo.type) {201case PromptsType.prompt:202return {203name: localize('promptsService.loadSlashCommands', 'Slash Commands Discovery'),204details: loadedCount === 1205? localize('promptsDebugContribution.resolvedSlashCommand', 'Resolved {0} slash command in {1}ms', loadedCount, durationInMillis)206: localize('promptsDebugContribution.resolvedSlashCommands', 'Resolved {0} slash commands in {1}ms', loadedCount, durationInMillis)207};208case PromptsType.agent:209return {210name: localize('promptsService.loadAgents', 'Agent Discovery'),211details: loadedCount === 1212? localize('promptsDebugContribution.resolvedAgent', 'Resolved {0} agent in {1}ms', loadedCount, durationInMillis)213: localize('promptsDebugContribution.resolvedAgents', 'Resolved {0} agents in {1}ms', loadedCount, durationInMillis)214};215case PromptsType.skill:216return {217name: localize('promptsService.loadSkills', 'Skill Discovery'),218details: loadedCount === 1219? localize('promptsDebugContribution.resolvedSkill', 'Resolved {0} skill in {1}ms', loadedCount, durationInMillis)220: localize('promptsDebugContribution.resolvedSkills', 'Resolved {0} skills in {1}ms', loadedCount, durationInMillis)221};222case PromptsType.instructions:223return {224name: localize('promptsService.loadInstructions', 'Instructions Discovery'),225details: loadedCount === 1226? localize('promptsDebugContribution.resolvedInstruction', 'Resolved {0} instruction in {1}ms', loadedCount, durationInMillis)227: localize('promptsDebugContribution.resolvedInstructions', 'Resolved {0} instructions in {1}ms', loadedCount, durationInMillis)228};229case PromptsType.hook: {230const hookDiscoveryInfo = discoveryInfo as IHookDiscoveryInfo;231const hookCount = hookDiscoveryInfo.hooksInfo232? Object.values(hookDiscoveryInfo.hooksInfo.hooks).reduce((total, hooks) => total + hooks.length, 0)233: loadedCount;234const details = skippedCount > 0235? localize('promptsDebugContribution.resolvedHooksWithSkipped', 'Resolved {0} hooks from {1} files in {2}ms, skipped {3}', hookCount, loadedCount, durationInMillis, skippedCount)236: hookCount === 1237? localize('promptsDebugContribution.resolvedHook', 'Resolved {0} hook in {1}ms', hookCount, durationInMillis)238: localize('promptsDebugContribution.resolvedHooks', 'Resolved {0} hooks in {1}ms', hookCount, durationInMillis);239return {240name: localize('promptsService.loadHooks', 'Hook Discovery'),241details242};243}244}245}246247private _resolveDiscoveryEvent(eventId: string): IChatDebugResolvedEventContent | undefined {248const info = this._discoveryEventDetails.get(eventId);249if (!info) {250return undefined;251}252253return this._toFileListContent(info);254}255256private _resolveCustomizationEvent(eventId: string): IChatDebugResolvedEventContent | undefined {257const data = this._customizationEventDetails.get(eventId);258if (!data) {259return undefined;260}261262const { debugInfo, hooks } = data;263const logs: IChatDebugCustomizationLogEntry[] = [...debugInfo.debugDetails];264265// Add hook entries from the resolved hooks — each command carries its sourceUri.266if (hooks) {267for (const hookType of Object.values(HookType)) {268const commands = hooks[hookType];269if (commands && commands.length > 0) {270for (const cmd of commands) {271const commandLabel = formatHookCommandLabel(cmd, OS) || localize('hook.unknownCommand', '(unknown command)');272logs.push({273category: 'hook',274name: commandLabel,275reason: hookType,276uri: cmd.sourceUri,277});278}279}280}281}282283return {284kind: 'customizationSummary',285resolutionLogs: logs,286durationInMillis: debugInfo.durationInMillis,287counts: {288instructions: logs.filter(e => e.category === 'applying' || e.category === 'referenced').length,289skills: logs.filter(e => e.category === 'skill').length,290agents: logs.filter(e => e.category === 'custom-agent').length,291hooks: logs.filter(e => e.category === 'hook').length,292skipped: logs.filter(e => e.category === 'skipped').length,293},294};295}296297private _toFileListContent(info: IPromptDiscoveryInfo): IChatDebugEventFileListContent {298return {299kind: 'fileList',300discoveryType: info.type,301durationInMillis: info.durationInMillis,302files: info.files.map(f => ({303uri: f.promptPath.uri,304name: f.promptPath.name,305status: f.status,306storage: f.promptPath.storage,307extensionId: f.promptPath.extension?.identifier.value,308skipReason: f.skipReason,309errorMessage: f.errorMessage,310duplicateOf: f.duplicateOf,311})),312sourceFolders: info.sourceFolders?.map(sf => ({313uri: sf.uri,314storage: sf.storage,315})),316};317}318}319320const MAX_LIST_ITEMS = 100;321322/**323* Join a list of strings, truncating after {@link MAX_LIST_ITEMS} entries.324* Full details are available via {@link IChatDebugService.resolveEvent}.325*/326function truncateList(items: string[]): string {327if (items.length <= MAX_LIST_ITEMS) {328return items.join(', ');329}330331return items.slice(0, MAX_LIST_ITEMS).join(', ') + ` (+${items.length - MAX_LIST_ITEMS} more)`;332}333334335