Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/vs/sessions/contrib/chat/browser/promptsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,8 @@ export class AgenticPromptsService extends PromptsService {
* Override to include built-in skills, appending them with lowest priority.
* Skills from any other source (workspace, user, extension, internal) take precedence.
*/
public override async findAgentSkills(token: CancellationToken, sessionResource?: URI): Promise<IAgentSkill[] | undefined> {
const baseResult = await super.findAgentSkills(token, sessionResource);
public override async findAgentSkills(token: CancellationToken): Promise<IAgentSkill[] | undefined> {
const baseResult = await super.findAgentSkills(token);
if (baseResult === undefined) {
return undefined;
}
Expand Down
171 changes: 121 additions & 50 deletions src/vs/workbench/contrib/chat/browser/promptsDebugContribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,19 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { CancellationTokenSource } from '../../../../base/common/cancellation.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { generateUuid } from '../../../../base/common/uuid.js';
import { localize } from '../../../../nls.js';
import { ILogService } from '../../../../platform/log/common/log.js';
import { IWorkbenchContribution } from '../../../common/contributions.js';
import { IChatDebugResolvedEventContent, IChatDebugService } from '../common/chatDebugService.js';
import { IPromptDiscoveryInfo, IPromptsService } from '../common/promptSyntax/service/promptsService.js';
import { IChatAgentService } from '../common/participants/chatAgents.js';
import { PromptsType } from '../common/promptSyntax/promptTypes.js';
import { IHookDiscoveryInfo, IPromptDiscoveryInfo, IPromptsService } from '../common/promptSyntax/service/promptsService.js';

/**
* Bridges {@link IPromptsService} discovery log events to {@link IChatDebugService}.
*
* This contribution listens for discovery events emitted by the prompts service
* and forwards them as debug log entries. It also registers a resolve provider
* so expanding a discovery event in the Agent Debug Logs shows the full file list.
* Bridges prompt discovery information to {@link IChatDebugService}.
*/
export class PromptsDebugContribution extends Disposable implements IWorkbenchContribution {

Expand All @@ -29,59 +30,74 @@ export class PromptsDebugContribution extends Disposable implements IWorkbenchCo
private readonly _discoveryEventDetails = new Map<string, IPromptDiscoveryInfo>();

constructor(
@IPromptsService promptsService: IPromptsService,
@IPromptsService private readonly promptsService: IPromptsService,
@IChatAgentService chatAgentService: IChatAgentService,
@IChatDebugService chatDebugService: IChatDebugService,
@ILogService logService: ILogService,
) {
super();

// Forward discovery log events to the debug service.
this._register(promptsService.onDidLogDiscovery(entry => {
let eventId: string | undefined;

if (entry.discoveryInfo) {
eventId = generateUuid();
this._discoveryEventDetails.set(eventId, entry.discoveryInfo);

// Evict oldest entries when the map exceeds the cap.
if (this._discoveryEventDetails.size > PromptsDebugContribution.MAX_DISCOVERY_DETAILS) {
const first = this._discoveryEventDetails.keys().next().value;
if (first !== undefined) {
this._discoveryEventDetails.delete(first);
this._register(chatAgentService.onWillInvokeAgent(async e => {
const sessionResource = e.request.sessionResource;
const cts = new CancellationTokenSource();

try {
const discoveryInfos = await Promise.all([PromptsType.agent, PromptsType.instructions, PromptsType.prompt, PromptsType.skill, PromptsType.hook].map(type => this.promptsService.getDiscoveryInfo(type, cts.token)));
for (const discoveryInfo of discoveryInfos) {
const { name, details } = this.getDiscoveryLogEntry(discoveryInfo);
const eventId = generateUuid();

this._discoveryEventDetails.set(eventId, discoveryInfo);

// Evict oldest entries when the map exceeds the cap.
if (this._discoveryEventDetails.size > PromptsDebugContribution.MAX_DISCOVERY_DETAILS) {
const first = this._discoveryEventDetails.keys().next().value;
if (first !== undefined) {
this._discoveryEventDetails.delete(first);
}
}
}
}

// Enrich details with file paths so they appear in the event
// payload (e.g. forwarded via onDidReceiveChatDebugEvent to the
// extension's JSONL file logger).
let details = entry.details;
if (entry.discoveryInfo) {
const info = entry.discoveryInfo;
const loaded = info.files
.filter(f => f.status === 'loaded')
.map(f => f.promptPath.name ?? f.promptPath.uri.path.split('/').pop() ?? f.promptPath.uri.toString());
const skipped = info.files.filter(f => f.status === 'skipped').map(f => {
const label = f.promptPath.uri.toString();
return f.skipReason ? `${label} (${f.skipReason})` : label;
});
const folders = info.sourceFolders?.map(sf => sf.uri.path) ?? [];
const parts: string[] = [];
if (details) { parts.push(details); }
if (loaded.length > 0) { parts.push(`loaded: [${truncateList(loaded)}]`); }
if (skipped.length > 0) { parts.push(`skipped: [${truncateList(skipped)}]`); }
if (folders.length > 0) { parts.push(`folders: [${truncateList(folders)}]`); }
details = parts.join(' | ') || undefined;
// Enrich details with file paths so they appear in the event
// payload (e.g. forwarded via onDidReceiveChatDebugEvent to the
// extension's JSONL file logger).
const loaded = discoveryInfo.files
.filter(f => f.status === 'loaded')
.map(f => f.promptPath.name ?? f.promptPath.uri.path.split('/').pop() ?? f.promptPath.uri.toString());
const skipped = discoveryInfo.files.filter(f => f.status === 'skipped').map(f => {
const label = f.promptPath.uri.toString();
return f.skipReason ? `${label} (${f.skipReason})` : label;
});
const folders = discoveryInfo.sourceFolders?.map(sf => sf.uri.path) ?? [];
const parts: string[] = [];
if (details) {
parts.push(details);
}
if (loaded.length > 0) {
parts.push(`loaded: [${truncateList(loaded)}]`);
}
if (skipped.length > 0) {
parts.push(`skipped: [${truncateList(skipped)}]`);
}
if (folders.length > 0) {
parts.push(`folders: [${truncateList(folders)}]`);
}
const newDetails = parts.join(' | ') || undefined;

chatDebugService.log(
sessionResource,
name,
newDetails,
undefined,
{ id: eventId, category: 'discovery' },
);
}
} catch (error) {
logService.error('Error while logging prompt discovery info to chat debug service', error);
} finally {
cts.dispose();
}

chatDebugService.log(
entry.sessionResource,
entry.name,
details,
undefined,
{ id: eventId, category: entry.category },
);
}));

// Register a resolve provider so expanding a discovery event
// in the Agent Debug Logs shows the full file list.
this._register(chatDebugService.registerProvider({
Expand All @@ -92,6 +108,59 @@ export class PromptsDebugContribution extends Disposable implements IWorkbenchCo
}));
}

private getDiscoveryLogEntry(discoveryInfo: IPromptDiscoveryInfo): { readonly name: string; readonly details?: string } {

const durationInMillis = discoveryInfo.durationInMillis.toFixed(1);
const loadedCount = discoveryInfo.files.filter(file => file.status === 'loaded').length;
const skippedCount = discoveryInfo.files.length - loadedCount;

switch (discoveryInfo.type) {
case PromptsType.prompt:
return {
name: localize('promptsService.loadSlashCommands', 'Load Slash Commands'),
details: loadedCount === 1
? localize('promptsDebugContribution.resolvedSlashCommand', 'Resolved {0} slash command in {1}ms', loadedCount, durationInMillis)
: localize('promptsDebugContribution.resolvedSlashCommands', 'Resolved {0} slash commands in {1}ms', loadedCount, durationInMillis)
};
case PromptsType.agent:
return {
name: localize('promptsService.loadAgents', 'Load Agents'),
details: loadedCount === 1
? localize('promptsDebugContribution.resolvedAgent', 'Resolved {0} agent in {1}ms', loadedCount, durationInMillis)
: localize('promptsDebugContribution.resolvedAgents', 'Resolved {0} agents in {1}ms', loadedCount, durationInMillis)
};
case PromptsType.skill:
return {
name: localize('promptsService.loadSkills', 'Load Skills'),
details: loadedCount === 1
? localize('promptsDebugContribution.resolvedSkill', 'Resolved {0} skill in {1}ms', loadedCount, durationInMillis)
: localize('promptsDebugContribution.resolvedSkills', 'Resolved {0} skills in {1}ms', loadedCount, durationInMillis)
};
case PromptsType.instructions:
return {
name: localize('promptsService.loadInstructions', 'Load Instructions'),
details: loadedCount === 1
? localize('promptsDebugContribution.resolvedInstruction', 'Resolved {0} instruction in {1}ms', loadedCount, durationInMillis)
: localize('promptsDebugContribution.resolvedInstructions', 'Resolved {0} instructions in {1}ms', loadedCount, durationInMillis)
};
case PromptsType.hook: {
const hookDiscoveryInfo = discoveryInfo as IHookDiscoveryInfo;
const hookCount = hookDiscoveryInfo.hooksInfo
? Object.values(hookDiscoveryInfo.hooksInfo.hooks).reduce((total, hooks) => total + hooks.length, 0)
: loadedCount;
const details = skippedCount > 0
? localize('promptsDebugContribution.resolvedHooksWithSkipped', 'Resolved {0} hooks from {1} files in {2}ms, skipped {3}', hookCount, loadedCount, durationInMillis, skippedCount)
: hookCount === 1
? localize('promptsDebugContribution.resolvedHook', 'Resolved {0} hook in {1}ms', hookCount, durationInMillis)
: localize('promptsDebugContribution.resolvedHooks', 'Resolved {0} hooks in {1}ms', hookCount, durationInMillis);
return {
name: localize('promptsService.loadHooks', 'Load Hooks'),
details
};
}
}
}

private _resolveDiscoveryEvent(eventId: string): IChatDebugResolvedEventContent | undefined {
const info = this._discoveryEventDetails.get(eventId);
if (!info) {
Expand All @@ -101,6 +170,7 @@ export class PromptsDebugContribution extends Disposable implements IWorkbenchCo
return {
kind: 'fileList',
discoveryType: info.type,
durationInMillis: info.durationInMillis,
files: info.files.map(f => ({
uri: f.promptPath.uri,
name: f.promptPath.name,
Expand Down Expand Up @@ -129,5 +199,6 @@ function truncateList(items: string[]): string {
if (items.length <= MAX_LIST_ITEMS) {
return items.join(', ');
}

return items.slice(0, MAX_LIST_ITEMS).join(', ') + ` (+${items.length - MAX_LIST_ITEMS} more)`;
}
3 changes: 1 addition & 2 deletions src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2817,8 +2817,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
this.logService.debug(`ChatWidget#_autoAttachInstructions: prompt files are enabled`);
const enabledTools = this.input.currentModeKind === ChatModeKind.Agent ? this.input.selectedToolsModel.userSelectedTools.get() : undefined;
const enabledSubAgents = this.input.currentModeKind === ChatModeKind.Agent ? this.input.currentModeObs.get().agents?.get() : undefined;
const sessionResource = this._viewModel?.model.sessionResource;
const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, this.input.currentModeKind, enabledTools, enabledSubAgents, sessionResource);
const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, this.input.currentModeKind, enabledTools, enabledSubAgents);
await computer.collect(attachedContext, CancellationToken.None);
} catch (err) {
this.logService.error(`ChatWidget#_autoAttachInstructions: failed to compute automatic instructions`, err);
Expand Down
1 change: 1 addition & 0 deletions src/vs/workbench/contrib/chat/common/chatDebugService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ export interface IChatDebugSourceFolderEntry {
export interface IChatDebugEventFileListContent {
readonly kind: 'fileList';
readonly discoveryType: string;
readonly durationInMillis: number;
readonly files: readonly IChatDebugFileEntry[];
readonly sourceFolders?: readonly IChatDebugSourceFolderEntry[];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1128,7 +1128,7 @@ export class ChatService extends Disposable implements IChatService {
let collectedHooks: ChatRequestHooks | undefined;
let hasDisabledClaudeHooks = false;
try {
const hooksInfo = await this.promptsService.getHooks(token, model.sessionResource);
const hooksInfo = await this.promptsService.getHooks(token);
if (hooksInfo) {
collectedHooks = hooksInfo.hooks;
hasDisabledClaudeHooks = hooksInfo.hasDisabledClaudeHooks;
Expand All @@ -1141,7 +1141,7 @@ export class ChatService extends Disposable implements IChatService {
const agentName = options?.modeInfo?.modeInstructions?.name;
if (agentName) {
try {
const agents = await this.promptsService.getCustomAgents(token, model.sessionResource);
const agents = await this.promptsService.getCustomAgents(token);
const customAgent = agents.find(a => a.name === agentName);
if (customAgent?.hooks) {
collectedHooks = mergeHooks(collectedHooks, customAgent.hooks);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,12 +218,18 @@ export interface IChatAgentCompletionItem {
command?: Command;
}

export interface IChatAgentInvocationEvent {
readonly agentId: string;
readonly request: Readonly<IChatAgentRequest>;
}

export interface IChatAgentService {
_serviceBrand: undefined;
/**
* undefined when an agent was removed
*/
readonly onDidChangeAgents: Event<IChatAgent | undefined>;
readonly onWillInvokeAgent: Event<IChatAgentInvocationEvent>;
readonly hasToolsAgent: boolean;
registerAgent(id: string, data: IChatAgentData): IDisposable;
registerAgentImplementation(id: string, agent: IChatAgentImplementation): IDisposable;
Expand Down Expand Up @@ -268,6 +274,8 @@ export class ChatAgentService extends Disposable implements IChatAgentService {

private readonly _onDidChangeAgents = this._register(new Emitter<IChatAgent | undefined>());
readonly onDidChangeAgents: Event<IChatAgent | undefined> = this._onDidChangeAgents.event;
private readonly _onWillInvokeAgent = this._register(new Emitter<IChatAgentInvocationEvent>());
readonly onWillInvokeAgent: Event<IChatAgentInvocationEvent> = this._onWillInvokeAgent.event;

private readonly _agentsContextKeys = new Set<string>();
private readonly _hasDefaultAgent: IContextKey<boolean>;
Expand Down Expand Up @@ -514,6 +522,7 @@ export class ChatAgentService extends Disposable implements IChatAgentService {
throw new Error(`No activated agent with id "${id}"`);
}

this._onWillInvokeAgent.fire({ agentId: id, request });
const result = await data.impl.invoke(request, progress, history, token);
markChat(request.sessionResource, ChatPerfMark.AgentDidInvoke);
return result;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ export class ComputeAutomaticInstructions {
private readonly _modeKind: ChatModeKind,
private readonly _enabledTools: UserSelectedTools | undefined,
private readonly _enabledSubagents: (readonly string[]) | undefined,
private readonly _sessionResource: URI | undefined,
@IPromptsService private readonly _promptsService: IPromptsService,
@ILogService public readonly _logService: ILogService,
@ILabelService private readonly _labelService: ILabelService,
Expand Down Expand Up @@ -99,7 +98,7 @@ export class ComputeAutomaticInstructions {

public async collect(variables: ChatRequestVariableSet, token: CancellationToken): Promise<void> {

const instructionFiles = await this._promptsService.getInstructionFiles(token, this._sessionResource);
const instructionFiles = await this._promptsService.getInstructionFiles(token);

this._logService.trace(`[InstructionsContextComputer] ${instructionFiles.length} instruction files available.`);

Expand Down Expand Up @@ -394,7 +393,7 @@ export class ComputeAutomaticInstructions {
entries.push('</instructions>', '', ''); // add trailing newline
}

const agentSkills = await this._promptsService.findAgentSkills(token, this._sessionResource);
const agentSkills = await this._promptsService.findAgentSkills(token);
// Filter out skills with disableModelInvocation=true (they can only be triggered manually via /name)
// Also filter by `when` clause using the scoped context key service
// Also filter out the troubleshoot skill when the feature flags are disabled
Expand Down Expand Up @@ -458,7 +457,7 @@ export class ComputeAutomaticInstructions {
return (agent: ICustomAgent) => subagents.includes(agent.name);
}
})();
const agents = await this._promptsService.getCustomAgents(token, this._sessionResource);
const agents = await this._promptsService.getCustomAgents(token);
if (agents.length > 0) {
entries.push('<agents>');
entries.push('Here is a list of agents that can be used when running a subagent.');
Expand Down
Loading
Loading