diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 65bcacd26e622..8f1aa64f97b7d 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -53,6 +53,10 @@ const _allApiProposals = { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts', version: 11 }, + chatPromptFiles: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts', + version: 1 + }, chatProvider: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatProvider.d.ts', version: 4 diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 1714bfacab9ca..6444ca9c12c12 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -26,7 +26,8 @@ import { IUriIdentityService } from '../../../platform/uriIdentity/common/uriIde import { IChatWidgetService } from '../../contrib/chat/browser/chat.js'; import { AddDynamicVariableAction, IAddDynamicVariableContext } from '../../contrib/chat/browser/attachments/chatDynamicVariables.js'; import { IChatAgentHistoryEntry, IChatAgentImplementation, IChatAgentRequest, IChatAgentService } from '../../contrib/chat/common/participants/chatAgents.js'; -import { ICustomAgentQueryOptions, IPromptsService } from '../../contrib/chat/common/promptSyntax/service/promptsService.js'; +import { IPromptFileContext, IPromptsService } from '../../contrib/chat/common/promptSyntax/service/promptsService.js'; +import { isValidPromptType } from '../../contrib/chat/common/promptSyntax/promptTypes.js'; import { IChatEditingService, IChatRelatedFileProviderMetadata } from '../../contrib/chat/common/editing/chatEditingService.js'; import { IChatModel } from '../../contrib/chat/common/model/chatModel.js'; import { ChatRequestAgentPart } from '../../contrib/chat/common/requestParser/chatParserTypes.js'; @@ -96,8 +97,8 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA private readonly _chatRelatedFilesProviders = this._register(new DisposableMap()); - private readonly _customAgentsProviders = this._register(new DisposableMap()); - private readonly _customAgentsProviderEmitters = this._register(new DisposableMap>()); + private readonly _promptFileProviders = this._register(new DisposableMap()); + private readonly _promptFileProviderEmitters = this._register(new DisposableMap>()); private readonly _pendingProgress = new Map void; chatSession: IChatModel | undefined }>(); private readonly _proxy: ExtHostChatAgentsShape2; @@ -435,41 +436,46 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA this._chatRelatedFilesProviders.deleteAndDispose(handle); } - async $registerCustomAgentsProvider(handle: number, extensionId: ExtensionIdentifier): Promise { + async $registerPromptFileProvider(handle: number, type: string, extensionId: ExtensionIdentifier): Promise { const extension = await this._extensionService.getExtension(extensionId.value); if (!extension) { - this._logService.error(`[MainThreadChatAgents2] Could not find extension for CustomAgentsProvider: ${extensionId.value}`); + this._logService.error(`[MainThreadChatAgents2] Could not find extension for prompt file provider: ${extensionId.value}`); + return; + } + + if (!isValidPromptType(type)) { + this._logService.error(`[MainThreadChatAgents2] Invalid contribution type: ${type}`); return; } const emitter = new Emitter(); - this._customAgentsProviderEmitters.set(handle, emitter); + this._promptFileProviderEmitters.set(handle, emitter); - const disposable = this._promptsService.registerCustomAgentsProvider(extension, { - onDidChangeCustomAgents: emitter.event, - provideCustomAgents: async (options: ICustomAgentQueryOptions, token: CancellationToken) => { - const agents = await this._proxy.$provideCustomAgents(handle, options, token); - if (!agents) { + const disposable = this._promptsService.registerPromptFileProvider(extension, type, { + onDidChangePromptFiles: emitter.event, + providePromptFiles: async (context: IPromptFileContext, token: CancellationToken) => { + const contributions = await this._proxy.$providePromptFiles(handle, type, context, token); + if (!contributions) { return undefined; } // Convert UriComponents to URI - return agents.map(agent => ({ - ...agent, - uri: URI.revive(agent.uri) + return contributions.map(c => ({ + ...c, + uri: URI.revive(c.uri) })); } }); - this._customAgentsProviders.set(handle, disposable); + this._promptFileProviders.set(handle, disposable); } - $unregisterCustomAgentsProvider(handle: number): void { - this._customAgentsProviders.deleteAndDispose(handle); - this._customAgentsProviderEmitters.deleteAndDispose(handle); + $unregisterPromptFileProvider(handle: number): void { + this._promptFileProviders.deleteAndDispose(handle); + this._promptFileProviderEmitters.deleteAndDispose(handle); } - $onDidChangeCustomAgents(handle: number): void { - const emitter = this._customAgentsProviderEmitters.get(handle); + $onDidChangePromptFiles(handle: number): void { + const emitter = this._promptFileProviderEmitters.get(handle); if (emitter) { emitter.fire(); } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index ccfd8731f6ef9..a77a0079ee02e 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -23,6 +23,7 @@ import { getRemoteName } from '../../../platform/remote/common/remoteHosts.js'; import { TelemetryTrustedValue } from '../../../platform/telemetry/common/telemetryUtils.js'; import { EditSessionIdentityMatch } from '../../../platform/workspace/common/editSessions.js'; import { DebugConfigurationProviderTriggerKind } from '../../contrib/debug/common/debug.js'; +import { PromptsType } from '../../contrib/chat/common/promptSyntax/promptTypes.js'; import { ExtensionDescriptionRegistry } from '../../services/extensions/common/extensionDescriptionRegistry.js'; import { UIKind } from '../../services/extensions/common/extensionHostProtocol.js'; import { checkProposedApiEnabled, isProposedApiEnabled } from '../../services/extensions/common/extensions.js'; @@ -1541,9 +1542,17 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatContextProvider'); return extHostChatContext.registerChatContextProvider(selector ? checkSelector(selector) : undefined, `${extension.id}-${id}`, provider); }, - registerCustomAgentsProvider(provider: vscode.CustomAgentsProvider): vscode.Disposable { - checkProposedApiEnabled(extension, 'chatParticipantPrivate'); - return extHostChatAgents2.registerCustomAgentsProvider(extension, provider); + registerCustomAgentProvider(provider: vscode.CustomAgentProvider): vscode.Disposable { + checkProposedApiEnabled(extension, 'chatPromptFiles'); + return extHostChatAgents2.registerPromptFileProvider(extension, PromptsType.agent, provider); + }, + registerInstructionsProvider(provider: vscode.InstructionsProvider): vscode.Disposable { + checkProposedApiEnabled(extension, 'chatPromptFiles'); + return extHostChatAgents2.registerPromptFileProvider(extension, PromptsType.instructions, provider); + }, + registerPromptFileProvider(provider: vscode.PromptFileProvider): vscode.Disposable { + checkProposedApiEnabled(extension, 'chatPromptFiles'); + return extHostChatAgents2.registerPromptFileProvider(extension, PromptsType.prompt, provider); }, }; diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index fa015c3f91269..d742bfe00dea1 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -65,7 +65,7 @@ import { IChatRequestVariableValue } from '../../contrib/chat/common/attachments import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { IChatMessage, IChatResponsePart, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatSelector } from '../../contrib/chat/common/languageModels.js'; import { IPreparedToolInvocation, IToolInvocation, IToolInvocationPreparationContext, IToolProgressStep, IToolResult, ToolDataSource } from '../../contrib/chat/common/tools/languageModelToolsService.js'; -import { ICustomAgentQueryOptions, IExternalCustomAgent } from '../../contrib/chat/common/promptSyntax/service/promptsService.js'; +import { IPromptFileContext, IPromptFileResource } from '../../contrib/chat/common/promptSyntax/service/promptsService.js'; import { DebugConfigurationProviderTriggerKind, IAdapterDescriptor, IConfig, IDebugSessionReplMode, IDebugTestRunReference, IDebugVisualization, IDebugVisualizationContext, IDebugVisualizationTreeItem, MainThreadDebugVisualization } from '../../contrib/debug/common/debug.js'; import { McpCollectionDefinition, McpConnectionState, McpServerDefinition, McpServerLaunch } from '../../contrib/mcp/common/mcpTypes.js'; import * as notebookCommon from '../../contrib/notebook/common/notebookCommon.js'; @@ -99,6 +99,7 @@ import { ISaveProfileResult } from '../../services/userDataProfile/common/userDa import { IExtHostDocumentSaveDelegate } from './extHostDocumentData.js'; import { TerminalShellExecutionCommandLineConfidence } from './extHostTypes.js'; import * as tasks from './shared/tasks.js'; +import { PromptsType } from '../../contrib/chat/common/promptSyntax/promptTypes.js'; export type IconPathDto = | UriComponents @@ -1394,9 +1395,9 @@ export interface MainThreadChatAgentsShape2 extends IChatAgentProgressShape, IDi $unregisterChatParticipantDetectionProvider(handle: number): void; $registerRelatedFilesProvider(handle: number, metadata: IChatRelatedFilesProviderMetadata): void; $unregisterRelatedFilesProvider(handle: number): void; - $registerCustomAgentsProvider(handle: number, extension: ExtensionIdentifier): void; - $unregisterCustomAgentsProvider(handle: number): void; - $onDidChangeCustomAgents(handle: number): void; + $registerPromptFileProvider(handle: number, type: string, extension: ExtensionIdentifier): void; + $unregisterPromptFileProvider(handle: number): void; + $onDidChangePromptFiles(handle: number): void; $registerAgentCompletionsProvider(handle: number, id: string, triggerCharacters: string[]): void; $unregisterAgentCompletionsProvider(handle: number, id: string): void; $updateAgent(handle: number, metadataUpdate: IExtensionChatAgentMetadata): void; @@ -1462,7 +1463,7 @@ export interface ExtHostChatAgentsShape2 { $releaseSession(sessionResource: UriComponents): void; $detectChatParticipant(handle: number, request: Dto, context: { history: IChatAgentHistoryEntryDto[] }, options: { participants: IChatParticipantMetadata[]; location: ChatAgentLocation }, token: CancellationToken): Promise; $provideRelatedFiles(handle: number, request: Dto, token: CancellationToken): Promise[] | undefined>; - $provideCustomAgents(handle: number, options: ICustomAgentQueryOptions, token: CancellationToken): Promise[] | undefined>; + $providePromptFiles(handle: number, type: PromptsType, context: IPromptFileContext, token: CancellationToken): Promise[] | undefined>; $setRequestTools(requestId: string, tools: UserSelectedTools): void; } export interface IChatParticipantMetadata { diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 31a52458824ee..493723aa848ed 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -35,7 +35,8 @@ import { ExtHostLanguageModels } from './extHostLanguageModels.js'; import { ExtHostLanguageModelTools } from './extHostLanguageModelTools.js'; import * as typeConvert from './extHostTypeConverters.js'; import * as extHostTypes from './extHostTypes.js'; -import { ICustomAgentQueryOptions, IExternalCustomAgent } from '../../contrib/chat/common/promptSyntax/service/promptsService.js'; +import { IPromptFileContext, IPromptFileResource } from '../../contrib/chat/common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../contrib/chat/common/promptSyntax/promptTypes.js'; import { ExtHostDocumentsAndEditors } from './extHostDocumentsAndEditors.js'; export class ChatAgentResponseStream { @@ -398,8 +399,8 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS private static _relatedFilesProviderIdPool = 0; private readonly _relatedFilesProviders = new Map(); - private static _customAgentsProviderIdPool = 0; - private readonly _customAgentsProviders = new Map(); + private static _contributionsProviderIdPool = 0; + private readonly _promptFileProviders = new Map(); private readonly _sessionDisposables: DisposableResourceMap = this._register(new DisposableResourceMap()); private readonly _completionDisposables: DisposableMap = this._register(new DisposableMap()); @@ -479,23 +480,41 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS }); } - registerCustomAgentsProvider(extension: IExtensionDescription, provider: vscode.CustomAgentsProvider): vscode.Disposable { - const handle = ExtHostChatAgents2._customAgentsProviderIdPool++; - this._customAgentsProviders.set(handle, { extension, provider }); - this._proxy.$registerCustomAgentsProvider(handle, extension.identifier); + /** + * Internal method that handles all prompt file provider types. + * Routes custom agents, instructions, and prompt files to the unified internal implementation. + */ + registerPromptFileProvider(extension: IExtensionDescription, type: PromptsType, provider: vscode.CustomAgentProvider | vscode.InstructionsProvider | vscode.PromptFileProvider): vscode.Disposable { + const handle = ExtHostChatAgents2._contributionsProviderIdPool++; + this._promptFileProviders.set(handle, { extension, provider }); + this._proxy.$registerPromptFileProvider(handle, type, extension.identifier); const disposables = new DisposableStore(); // Listen to provider change events and notify main thread - if (provider.onDidChangeCustomAgents) { - disposables.add(provider.onDidChangeCustomAgents(() => { - this._proxy.$onDidChangeCustomAgents(handle); + // Check for the appropriate event based on the provider type + let changeEvent: vscode.Event | undefined; + switch (type) { + case PromptsType.agent: + changeEvent = (provider as vscode.CustomAgentProvider).onDidChangeCustomAgents; + break; + case PromptsType.instructions: + changeEvent = (provider as vscode.InstructionsProvider).onDidChangeInstructions; + break; + case PromptsType.prompt: + changeEvent = (provider as vscode.PromptFileProvider).onDidChangePromptFiles; + break; + } + + if (changeEvent) { + disposables.add(changeEvent(() => { + this._proxy.$onDidChangePromptFiles(handle); })); } disposables.add(toDisposable(() => { - this._customAgentsProviders.delete(handle); - this._proxy.$unregisterCustomAgentsProvider(handle); + this._promptFileProviders.delete(handle); + this._proxy.$unregisterPromptFileProvider(handle); })); return disposables; @@ -511,13 +530,21 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS return await provider.provider.provideRelatedFiles(extRequestDraft, token) ?? undefined; } - async $provideCustomAgents(handle: number, options: ICustomAgentQueryOptions, token: CancellationToken): Promise { - const providerData = this._customAgentsProviders.get(handle); + async $providePromptFiles(handle: number, type: PromptsType, context: IPromptFileContext, token: CancellationToken): Promise { + const providerData = this._promptFileProviders.get(handle); if (!providerData) { - return Promise.resolve(undefined); + return undefined; } - return await providerData.provider.provideCustomAgents(options, token) ?? undefined; + const provider = providerData.provider; + switch (type) { + case PromptsType.agent: + return await (provider as vscode.CustomAgentProvider).provideCustomAgents(context, token) ?? undefined; + case PromptsType.instructions: + return await (provider as vscode.InstructionsProvider).provideInstructions(context, token) ?? undefined; + case PromptsType.prompt: + return await (provider as vscode.PromptFileProvider).providePromptFiles(context, token) ?? undefined; + } } async $detectChatParticipant(handle: number, requestDto: Dto, context: { history: IChatAgentHistoryEntryDto[] }, options: { location: ChatAgentLocation; participants?: vscode.ChatParticipantMetadata[] }, token: CancellationToken): Promise { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index db0c3e5894869..7d7f205164543 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -16,29 +16,21 @@ import { IHandOff, ParsedPromptFile } from '../promptFileParser.js'; import { ResourceSet } from '../../../../../../base/common/map.js'; /** - * Activation event for custom agent providers. + * Activation events for prompt file providers. */ -export const CUSTOM_AGENTS_PROVIDER_ACTIVATION_EVENT = 'onCustomAgentsProvider'; +export const CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT = 'onCustomAgentProvider'; +export const INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT = 'onInstructionsProvider'; +export const PROMPT_FILE_PROVIDER_ACTIVATION_EVENT = 'onPromptFileProvider'; /** - * Options for querying custom agents. + * Context for querying prompt files. */ -export interface ICustomAgentQueryOptions { } +export interface IPromptFileContext { } /** - * Represents a custom agent resource from an external provider. + * Represents a prompt file resource from an external provider. */ -export interface IExternalCustomAgent { - /** - * The unique identifier/name of the custom agent resource. - */ - readonly name: string; - - /** - * A description of what the custom agent resource does. - */ - readonly description: string; - +export interface IPromptFileResource { /** * The URI to the agent or prompt resource file. */ @@ -316,15 +308,15 @@ export interface IPromptsService extends IDisposable { setDisabledPromptFiles(type: PromptsType, uris: ResourceSet): void; /** - * Registers a CustomAgentsProvider that can provide custom agents for repositories. - * This is part of the proposed API and requires the chatParticipantPrivate proposal. + * Registers a prompt file provider that can provide prompt files for repositories. * @param extension The extension registering the provider. + * @param type The type of contribution. * @param provider The provider implementation with optional change event. * @returns A disposable that unregisters the provider when disposed. */ - registerCustomAgentsProvider(extension: IExtensionDescription, provider: { - onDidChangeCustomAgents?: Event; - provideCustomAgents: (options: ICustomAgentQueryOptions, token: CancellationToken) => Promise; + registerPromptFileProvider(extension: IExtensionDescription, type: PromptsType, provider: { + onDidChangePromptFiles?: Event; + providePromptFiles: (context: IPromptFileContext, token: CancellationToken) => Promise; }): IDisposable; /** 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 8067559def970..63a78454b07db 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -32,7 +32,7 @@ import { getCleanPromptName } from '../config/promptFileLocations.js'; import { PROMPT_LANGUAGE_ID, PromptsType, getPromptsTypeForLanguageId } from '../promptTypes.js'; import { PromptFilesLocator } from '../utils/promptFilesLocator.js'; import { PromptFileParser, ParsedPromptFile, PromptHeaderAttributes } from '../promptFileParser.js'; -import { IAgentInstructions, IAgentSource, IChatPromptSlashCommand, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPromptPath, IPromptsService, IAgentSkill, IUserPromptPath, PromptsStorage, ICustomAgentQueryOptions, IExternalCustomAgent, ExtensionAgentSourceType, CUSTOM_AGENTS_PROVIDER_ACTIVATION_EVENT } from './promptsService.js'; +import { IAgentInstructions, IAgentSource, IChatPromptSlashCommand, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPromptPath, IPromptsService, IAgentSkill, IUserPromptPath, PromptsStorage, ExtensionAgentSourceType, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT } from './promptsService.js'; import { Delayer } from '../../../../../../base/common/async.js'; import { Schemas } from '../../../../../../base/common/network.js'; @@ -165,46 +165,71 @@ export class PromptsService extends Disposable implements IPromptsService { } /** - * Registry of CustomAgentsProvider instances. Extensions can register providers via the proposed API. + * Registry of prompt file provider instances (custom agents, instructions, prompt files). + * Extensions can register providers via the proposed API. */ - private readonly customAgentsProviders: Array<{ + private readonly promptFileProviders: Array<{ extension: IExtensionDescription; - onDidChangeCustomAgents?: Event; - provideCustomAgents: (options: ICustomAgentQueryOptions, token: CancellationToken) => Promise; + type: PromptsType; + onDidChangePromptFiles?: Event; + providePromptFiles: (context: IPromptFileContext, token: CancellationToken) => Promise; }> = []; /** - * Registers a CustomAgentsProvider. This will be called by the extension host bridge when - * an extension registers a provider via vscode.chat.registerCustomAgentsProvider(). + * Registers a prompt file provider (CustomAgentProvider, InstructionsProvider, or PromptFileProvider). + * This will be called by the extension host bridge when + * an extension registers a provider via vscode.chat.registerCustomAgentProvider(), + * registerInstructionsProvider(), or registerPromptFileProvider(). */ - public registerCustomAgentsProvider(extension: IExtensionDescription, provider: { - onDidChangeCustomAgents?: Event; - provideCustomAgents: (options: ICustomAgentQueryOptions, token: CancellationToken) => Promise; + public registerPromptFileProvider(extension: IExtensionDescription, type: PromptsType, provider: { + onDidChangePromptFiles?: Event; + providePromptFiles: (context: IPromptFileContext, token: CancellationToken) => Promise; }): IDisposable { - const providerEntry = { extension, ...provider }; - this.customAgentsProviders.push(providerEntry); + const providerEntry = { extension, type, ...provider }; + this.promptFileProviders.push(providerEntry); const disposables = new DisposableStore(); // Listen to provider change events to rerun computeListPromptFiles - if (provider.onDidChangeCustomAgents) { - disposables.add(provider.onDidChangeCustomAgents(() => { - this.cachedFileLocations[PromptsType.agent] = undefined; - this.cachedCustomAgents.refresh(); + if (provider.onDidChangePromptFiles) { + disposables.add(provider.onDidChangePromptFiles(() => { + if (type === PromptsType.agent) { + this.cachedFileLocations[PromptsType.agent] = undefined; + this.cachedCustomAgents.refresh(); + } else if (type === PromptsType.instructions) { + this.cachedFileLocations[PromptsType.instructions] = undefined; + } else if (type === PromptsType.prompt) { + this.cachedFileLocations[PromptsType.prompt] = undefined; + this.cachedSlashCommands.refresh(); + } })); } - // Invalidate agent cache when providers change - this.cachedFileLocations[PromptsType.agent] = undefined; - this.cachedCustomAgents.refresh(); + // Invalidate cache when providers change + if (type === PromptsType.agent) { + this.cachedFileLocations[PromptsType.agent] = undefined; + this.cachedCustomAgents.refresh(); + } else if (type === PromptsType.instructions) { + this.cachedFileLocations[PromptsType.instructions] = undefined; + } else if (type === PromptsType.prompt) { + this.cachedFileLocations[PromptsType.prompt] = undefined; + this.cachedSlashCommands.refresh(); + } disposables.add({ dispose: () => { - const index = this.customAgentsProviders.findIndex((p) => p === providerEntry); + const index = this.promptFileProviders.findIndex((p) => p === providerEntry); if (index >= 0) { - this.customAgentsProviders.splice(index, 1); - this.cachedFileLocations[PromptsType.agent] = undefined; - this.cachedCustomAgents.refresh(); + this.promptFileProviders.splice(index, 1); + if (type === PromptsType.agent) { + this.cachedFileLocations[PromptsType.agent] = undefined; + this.cachedCustomAgents.refresh(); + } else if (type === PromptsType.instructions) { + this.cachedFileLocations[PromptsType.instructions] = undefined; + } else if (type === PromptsType.prompt) { + this.cachedFileLocations[PromptsType.prompt] = undefined; + this.cachedSlashCommands.refresh(); + } } } }); @@ -212,46 +237,48 @@ export class PromptsService extends Disposable implements IPromptsService { return disposables; } - private async listCustomAgentsFromProvider(token: CancellationToken): Promise { + /** + * Shared helper to list prompt files from registered providers for a given type. + */ + private async listFromProviders(type: PromptsType, activationEvent: string, token: CancellationToken): Promise { const result: IPromptPath[] = []; - if (this.customAgentsProviders.length === 0) { + // Activate extensions that might provide files for this type + await this.extensionService.activateByEvent(activationEvent); + + const providers = this.promptFileProviders.filter(p => p.type === type); + if (providers.length === 0) { return result; } - // Activate extensions that might provide custom agents - await this.extensionService.activateByEvent(CUSTOM_AGENTS_PROVIDER_ACTIVATION_EVENT); - - // Collect agents from all providers - for (const providerEntry of this.customAgentsProviders) { + // Collect files from all providers + for (const providerEntry of providers) { try { - const agents = await providerEntry.provideCustomAgents({}, token); - if (!agents || token.isCancellationRequested) { + const files = await providerEntry.providePromptFiles({}, token); + if (!files || token.isCancellationRequested) { continue; } - for (const agent of agents) { - if (!agent.isEditable) { + for (const file of files) { + if (!file.isEditable) { try { - await this.filesConfigService.updateReadonly(agent.uri, true); + await this.filesConfigService.updateReadonly(file.uri, true); } catch (e) { const msg = e instanceof Error ? e.message : String(e); - this.logger.error(`[listCustomAgentsFromProvider] Failed to make agent file readonly: ${agent.uri}`, msg); + this.logger.error(`[listFromProviders] Failed to make file readonly: ${file.uri}`, msg); } } result.push({ - uri: agent.uri, - name: agent.name, - description: agent.description, + uri: file.uri, storage: PromptsStorage.extension, - type: PromptsType.agent, + type, extension: providerEntry.extension, source: ExtensionAgentSourceType.provider } satisfies IExtensionPromptPath); } } catch (e) { - this.logger.error(`[listCustomAgentsFromProvider] Failed to get custom agents from provider`, e instanceof Error ? e.message : String(e)); + this.logger.error(`[listFromProviders] Failed to get ${type} files from provider`, e instanceof Error ? e.message : String(e)); } } @@ -276,11 +303,21 @@ export class PromptsService extends Disposable implements IPromptsService { private async getExtensionPromptFiles(type: PromptsType, token: CancellationToken): Promise { await this.extensionService.whenInstalledExtensionsRegistered(); const contributedFiles = await Promise.all(this.contributedFiles[type].values()); - if (type === PromptsType.agent) { - const providerAgents = await this.listCustomAgentsFromProvider(token); - return [...contributedFiles, ...providerAgents]; + + const activationEvent = this.getProviderActivationEvent(type); + const providerFiles = await this.listFromProviders(type, activationEvent, token); + return [...contributedFiles, ...providerFiles]; + } + + private getProviderActivationEvent(type: PromptsType): string { + switch (type) { + case PromptsType.agent: + return CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT; + case PromptsType.instructions: + return INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT; + case PromptsType.prompt: + return PROMPT_FILE_PROVIDER_ACTIVATION_EVENT; } - return contributedFiles; } public getSourceFolders(type: PromptsType): readonly IPromptPath[] { diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts index 04fb873efef83..f71149b121891 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts @@ -11,7 +11,7 @@ import { ITextModel } from '../../../../../../../editor/common/model.js'; import { IExtensionDescription } from '../../../../../../../platform/extensions/common/extensions.js'; import { PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; import { ParsedPromptFile } from '../../../../common/promptSyntax/promptFileParser.js'; -import { IAgentSkill, ICustomAgent, ICustomAgentQueryOptions, IExternalCustomAgent, IPromptPath, IPromptsService, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; +import { IAgentSkill, ICustomAgent, IPromptFileContext, IPromptFileResource, IPromptPath, IPromptsService, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; import { ResourceSet } from '../../../../../../../base/common/map.js'; export class MockPromptsService implements IPromptsService { @@ -60,7 +60,7 @@ export class MockPromptsService implements IPromptsService { getAgentFileURIFromModeFile(oldURI: URI): URI | undefined { throw new Error('Not implemented'); } getDisabledPromptFiles(type: PromptsType): ResourceSet { throw new Error('Method not implemented.'); } setDisabledPromptFiles(type: PromptsType, uris: ResourceSet): void { throw new Error('Method not implemented.'); } - registerCustomAgentsProvider(extension: IExtensionDescription, provider: { provideCustomAgents: (options: ICustomAgentQueryOptions, token: CancellationToken) => Promise }): IDisposable { throw new Error('Method not implemented.'); } + registerPromptFileProvider(extension: IExtensionDescription, type: PromptsType, provider: { providePromptFiles: (context: IPromptFileContext, token: CancellationToken) => Promise }): IDisposable { throw new Error('Method not implemented.'); } findAgentSkills(token: CancellationToken): Promise { throw new Error('Method not implemented.'); } dispose(): void { } } diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 8179652e429db..55845c7bcc19a 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -36,7 +36,7 @@ import { ComputeAutomaticInstructions, newInstructionsCollectionEvent } from '.. import { PromptsConfig } from '../../../../common/promptSyntax/config/config.js'; import { INSTRUCTION_FILE_EXTENSION, INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION } from '../../../../common/promptSyntax/config/promptFileLocations.js'; import { INSTRUCTIONS_LANGUAGE_ID, PROMPT_LANGUAGE_ID, PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; -import { ExtensionAgentSourceType, ICustomAgent, ICustomAgentQueryOptions, IPromptsService, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; +import { ExtensionAgentSourceType, ICustomAgent, IPromptFileContext, IPromptsService, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; import { PromptsService } from '../../../../common/promptSyntax/service/promptsServiceImpl.js'; import { mockFiles } from '../testUtils/mockFilesystem.js'; import { InMemoryStorageService, IStorageService } from '../../../../../../../platform/storage/common/storage.js'; @@ -1103,18 +1103,16 @@ suite('PromptsService', () => { ]); const provider = { - provideCustomAgents: async (_options: ICustomAgentQueryOptions, _token: CancellationToken) => { + providePromptFiles: async (_context: IPromptFileContext, _token: CancellationToken) => { return [ { - name: 'myAgent', - description: 'My custom agent from provider', uri: agentUri } ]; } }; - const registered = service.registerCustomAgentsProvider(extension, provider); + const registered = service.registerPromptFileProvider(extension, PromptsType.agent, provider); const actual = await service.getCustomAgents(CancellationToken.None); assert.strictEqual(actual.length, 1); @@ -1122,9 +1120,6 @@ suite('PromptsService', () => { assert.strictEqual(actual[0].description, 'My custom agent from provider'); assert.strictEqual(actual[0].uri.toString(), agentUri.toString()); assert.strictEqual(actual[0].source.storage, PromptsStorage.extension); - if (actual[0].source.storage === PromptsStorage.extension) { - assert.strictEqual(actual[0].source.type, ExtensionAgentSourceType.provider); - } registered.dispose(); @@ -1164,17 +1159,13 @@ suite('PromptsService', () => { ]); const provider = { - provideCustomAgents: async (_options: ICustomAgentQueryOptions, _token: CancellationToken) => { + providePromptFiles: async (_context: IPromptFileContext, _token: CancellationToken) => { return [ { - name: 'readonlyAgent', - description: 'Readonly agent from provider', uri: readonlyAgentUri, isEditable: false }, { - name: 'editableAgent', - description: 'Editable agent from provider', uri: editableAgentUri, isEditable: true } @@ -1182,7 +1173,7 @@ suite('PromptsService', () => { } }; - const registered = service.registerCustomAgentsProvider(extension, provider); + const registered = service.registerPromptFileProvider(extension, PromptsType.agent, provider); // Spy on updateReadonly to verify it's called correctly const filesConfigService = instaService.get(IFilesConfigurationService); @@ -1261,6 +1252,220 @@ suite('PromptsService', () => { }); }); + test('Instructions provider', async () => { + const instructionUri = URI.parse('file://extensions/my-extension/myInstruction.instructions.md'); + const extension = { + identifier: { value: 'test.my-extension' }, + enabledApiProposals: ['chatParticipantPrivate'] + } as unknown as IExtensionDescription; + + // Mock the instruction file content + await mockFiles(fileService, [ + { + path: instructionUri.path, + contents: [ + '# Test instruction content' + ] + } + ]); + + const provider = { + providePromptFiles: async (_context: IPromptFileContext, _token: CancellationToken) => { + return [ + { + uri: instructionUri + } + ]; + } + }; + + const registered = service.registerPromptFileProvider(extension, PromptsType.instructions, provider); + + const actual = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None); + const providerInstruction = actual.find(i => i.uri.toString() === instructionUri.toString()); + + assert.ok(providerInstruction, 'Provider instruction should be found'); + assert.strictEqual(providerInstruction!.uri.toString(), instructionUri.toString()); + assert.strictEqual(providerInstruction!.storage, PromptsStorage.extension); + assert.strictEqual(providerInstruction!.source, ExtensionAgentSourceType.provider); + + registered.dispose(); + + // After disposal, the instruction should no longer be listed + const actualAfterDispose = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None); + const foundAfterDispose = actualAfterDispose.find(i => i.uri.toString() === instructionUri.toString()); + assert.strictEqual(foundAfterDispose, undefined); + }); + + test('Instructions provider with isEditable flag', async () => { + const readonlyInstructionUri = URI.parse('file://extensions/my-extension/readonly.instructions.md'); + const editableInstructionUri = URI.parse('file://extensions/my-extension/editable.instructions.md'); + const extension = { + identifier: { value: 'test.my-extension' }, + enabledApiProposals: ['chatParticipantPrivate'] + } as unknown as IExtensionDescription; + + // Mock the instruction file content + await mockFiles(fileService, [ + { + path: readonlyInstructionUri.path, + contents: [ + '# Readonly instruction content' + ] + }, + { + path: editableInstructionUri.path, + contents: [ + '# Editable instruction content' + ] + } + ]); + + const provider = { + providePromptFiles: async (_context: IPromptFileContext, _token: CancellationToken) => { + return [ + { + uri: readonlyInstructionUri, + isEditable: false + }, + { + uri: editableInstructionUri, + isEditable: true + } + ]; + } + }; + + const registered = service.registerPromptFileProvider(extension, PromptsType.instructions, provider); + + // Spy on updateReadonly to verify it's called correctly + const filesConfigService = instaService.get(IFilesConfigurationService); + const updateReadonlySpy = sinon.spy(filesConfigService, 'updateReadonly'); + + // List prompt files to trigger the readonly check + await service.listPromptFiles(PromptsType.instructions, CancellationToken.None); + + // Verify updateReadonly was called only for the non-editable instruction + assert.strictEqual(updateReadonlySpy.callCount, 1, 'updateReadonly should be called once'); + assert.ok(updateReadonlySpy.calledWith(readonlyInstructionUri, true), 'updateReadonly should be called with readonly instruction URI and true'); + + const actual = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None); + const readonlyInstruction = actual.find(i => i.uri.toString() === readonlyInstructionUri.toString()); + const editableInstruction = actual.find(i => i.uri.toString() === editableInstructionUri.toString()); + + assert.ok(readonlyInstruction, 'Readonly instruction should be found'); + assert.ok(editableInstruction, 'Editable instruction should be found'); + + registered.dispose(); + }); + + test('Prompt file provider', async () => { + const promptUri = URI.parse('file://extensions/my-extension/myPrompt.prompt.md'); + const extension = { + identifier: { value: 'test.my-extension' }, + enabledApiProposals: ['chatParticipantPrivate'] + } as unknown as IExtensionDescription; + + // Mock the prompt file content + await mockFiles(fileService, [ + { + path: promptUri.path, + contents: [ + '# Test prompt content' + ] + } + ]); + + const provider = { + providePromptFiles: async (_context: IPromptFileContext, _token: CancellationToken) => { + return [ + { + uri: promptUri + } + ]; + } + }; + + const registered = service.registerPromptFileProvider(extension, PromptsType.prompt, provider); + + const actual = await service.listPromptFiles(PromptsType.prompt, CancellationToken.None); + const providerPrompt = actual.find(i => i.uri.toString() === promptUri.toString()); + + assert.ok(providerPrompt, 'Provider prompt should be found'); + assert.strictEqual(providerPrompt!.uri.toString(), promptUri.toString()); + assert.strictEqual(providerPrompt!.storage, PromptsStorage.extension); + assert.strictEqual(providerPrompt!.source, ExtensionAgentSourceType.provider); + + registered.dispose(); + + // After disposal, the prompt should no longer be listed + const actualAfterDispose = await service.listPromptFiles(PromptsType.prompt, CancellationToken.None); + const foundAfterDispose = actualAfterDispose.find(i => i.uri.toString() === promptUri.toString()); + assert.strictEqual(foundAfterDispose, undefined); + }); + + test('Prompt file provider with isEditable flag', async () => { + const readonlyPromptUri = URI.parse('file://extensions/my-extension/readonly.prompt.md'); + const editablePromptUri = URI.parse('file://extensions/my-extension/editable.prompt.md'); + const extension = { + identifier: { value: 'test.my-extension' }, + enabledApiProposals: ['chatParticipantPrivate'] + } as unknown as IExtensionDescription; + + // Mock the prompt file content + await mockFiles(fileService, [ + { + path: readonlyPromptUri.path, + contents: [ + '# Readonly prompt content' + ] + }, + { + path: editablePromptUri.path, + contents: [ + '# Editable prompt content' + ] + } + ]); + + const provider = { + providePromptFiles: async (_context: IPromptFileContext, _token: CancellationToken) => { + return [ + { + uri: readonlyPromptUri, + isEditable: false + }, + { + uri: editablePromptUri, + isEditable: true + } + ]; + } + }; + + const registered = service.registerPromptFileProvider(extension, PromptsType.prompt, provider); + + // Spy on updateReadonly to verify it's called correctly + const filesConfigService = instaService.get(IFilesConfigurationService); + const updateReadonlySpy = sinon.spy(filesConfigService, 'updateReadonly'); + + // List prompt files to trigger the readonly check + await service.listPromptFiles(PromptsType.prompt, CancellationToken.None); + + // Verify updateReadonly was called only for the non-editable prompt + assert.strictEqual(updateReadonlySpy.callCount, 1, 'updateReadonly should be called once'); + assert.ok(updateReadonlySpy.calledWith(readonlyPromptUri, true), 'updateReadonly should be called with readonly prompt URI and true'); + + const actual = await service.listPromptFiles(PromptsType.prompt, CancellationToken.None); + const readonlyPrompt = actual.find(i => i.uri.toString() === readonlyPromptUri.toString()); + const editablePrompt = actual.find(i => i.uri.toString() === editablePromptUri.toString()); + + assert.ok(readonlyPrompt, 'Readonly prompt should be found'); + assert.ok(editablePrompt, 'Editable prompt should be found'); + + registered.dispose(); + }); + suite('findAgentSkills', () => { teardown(() => { sinon.restore(); diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index c4a40a62c0014..6b6c670a5273e 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -319,65 +319,4 @@ declare module 'vscode' { } // #endregion - - // #region CustomAgentsProvider - - /** - * Represents a custom agent resource file (e.g., .agent.md or .prompt.md) available for a repository. - */ - export interface CustomAgentResource { - /** - * The unique identifier/name of the custom agent resource. - */ - readonly name: string; - - /** - * A description of what the custom agent resource does. - */ - readonly description: string; - - /** - * The URI to the agent or prompt resource file. - */ - readonly uri: Uri; - - /** - * Indicates whether the custom agent resource is editable. Defaults to false. - */ - readonly isEditable?: boolean; - } - - /** - * Options for querying custom agents. - */ - export interface CustomAgentQueryOptions { } - - /** - * A provider that supplies custom agent resources (from .agent.md and .prompt.md files) for repositories. - */ - export interface CustomAgentsProvider { - /** - * An optional event to signal that custom agents have changed. - */ - readonly onDidChangeCustomAgents?: Event; - - /** - * Provide the list of custom agent resources available for a given repository. - * @param options Optional query parameters. - * @param token A cancellation token. - * @returns An array of custom agent resources or a promise that resolves to such. - */ - provideCustomAgents(options: CustomAgentQueryOptions, token: CancellationToken): ProviderResult; - } - - export namespace chat { - /** - * Register a provider for custom agents. - * @param provider The custom agents provider. - * @returns A disposable that unregisters the provider when disposed. - */ - export function registerCustomAgentsProvider(provider: CustomAgentsProvider): Disposable; - } - - // #endregion } diff --git a/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts b/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts new file mode 100644 index 0000000000000..8a9017558070c --- /dev/null +++ b/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts @@ -0,0 +1,177 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// version: 1 + +declare module 'vscode' { + + // #region CustomAgentProvider + + /** + * Represents a custom agent resource file (e.g., .agent.md) available for a repository. + */ + export interface CustomAgentResource { + /** + * The URI to the custom agent resource file. + */ + readonly uri: Uri; + + /** + * Indicates whether the custom agent is editable. Defaults to false. + */ + readonly isEditable?: boolean; + } + + /** + * Context for querying custom agents. + */ + export type CustomAgentContext = object; + + /** + * A provider that supplies custom agent resources (from .agent.md files) for repositories. + */ + export interface CustomAgentProvider { + /** + * A human-readable label for this provider. + */ + readonly label: string; + + /** + * An optional event to signal that custom agents have changed. + */ + readonly onDidChangeCustomAgents?: Event; + + /** + * Provide the list of custom agents available. + * @param context Context for the query. + * @param token A cancellation token. + * @returns An array of custom agent resources or a promise that resolves to such. + */ + provideCustomAgents(context: CustomAgentContext, token: CancellationToken): ProviderResult; + } + + // #endregion + + // #region InstructionsProvider + + /** + * Represents an instructions resource file available for a repository. + */ + export interface InstructionsResource { + /** + * The URI to the instructions resource file. + */ + readonly uri: Uri; + + /** + * Indicates whether the instructions are editable. Defaults to false. + */ + readonly isEditable?: boolean; + } + + /** + * Context for querying instructions. + */ + export type InstructionsContext = object; + + /** + * A provider that supplies instructions resources for repositories. + */ + export interface InstructionsProvider { + /** + * A human-readable label for this provider. + */ + readonly label: string; + + /** + * An optional event to signal that instructions have changed. + */ + readonly onDidChangeInstructions?: Event; + + /** + * Provide the list of instructions available. + * @param context Context for the query. + * @param token A cancellation token. + * @returns An array of instructions resources or a promise that resolves to such. + */ + provideInstructions(context: InstructionsContext, token: CancellationToken): ProviderResult; + } + + // #endregion + + // #region PromptFileProvider + + /** + * Represents a prompt file resource (e.g., .prompt.md) available for a repository. + */ + export interface PromptFileResource { + /** + * The URI to the prompt file resource. + */ + readonly uri: Uri; + + /** + * Indicates whether the prompt file is editable. Defaults to false. + */ + readonly isEditable?: boolean; + } + + /** + * Context for querying prompt files. + */ + export type PromptFileContext = object; + + /** + * A provider that supplies prompt file resources (from .prompt.md files) for repositories. + */ + export interface PromptFileProvider { + /** + * A human-readable label for this provider. + */ + readonly label: string; + + /** + * An optional event to signal that prompt files have changed. + */ + readonly onDidChangePromptFiles?: Event; + + /** + * Provide the list of prompt files available. + * @param context Context for the query. + * @param token A cancellation token. + * @returns An array of prompt file resources or a promise that resolves to such. + */ + providePromptFiles(context: PromptFileContext, token: CancellationToken): ProviderResult; + } + + // #endregion + + // #region Chat Provider Registration + + export namespace chat { + /** + * Register a provider for custom agents. + * @param provider The custom agent provider. + * @returns A disposable that unregisters the provider when disposed. + */ + export function registerCustomAgentProvider(provider: CustomAgentProvider): Disposable; + + /** + * Register a provider for instructions. + * @param provider The instructions provider. + * @returns A disposable that unregisters the provider when disposed. + */ + export function registerInstructionsProvider(provider: InstructionsProvider): Disposable; + + /** + * Register a provider for prompt files. + * @param provider The prompt file provider. + * @returns A disposable that unregisters the provider when disposed. + */ + export function registerPromptFileProvider(provider: PromptFileProvider): Disposable; + } + + // #endregion +}