From d2865ebb0c9bbf071ca8021cb210e91dfe5ff462 Mon Sep 17 00:00:00 2001 From: Paul Date: Wed, 26 Nov 2025 12:57:19 -0800 Subject: [PATCH] Add support for GH Custom Agents (#278251) --- .../api/browser/mainThreadChatAgents2.ts | 45 +++++++ .../workbench/api/common/extHost.api.impl.ts | 7 +- .../workbench/api/common/extHost.protocol.ts | 5 + .../api/common/extHostChatAgents2.ts | 35 ++++++ src/vs/workbench/api/common/extHostTypes.ts | 5 + .../modelPicker/modePickerActionItem.ts | 4 +- .../contrib/chat/common/chatModes.ts | 8 +- .../promptSyntax/service/promptsService.ts | 60 +++++++++ .../service/promptsServiceImpl.ts | 115 ++++++++++++++++-- .../chat/test/common/mockPromptsService.ts | 3 +- .../service/promptsService.test.ts | 54 +++++++- ...scode.proposed.chatParticipantPrivate.d.ts | 69 +++++++++++ 12 files changed, 392 insertions(+), 18 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index 1e229af643a..74852ab652c 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -26,6 +26,7 @@ import { IUriIdentityService } from '../../../platform/uriIdentity/common/uriIde import { IChatWidgetService } from '../../contrib/chat/browser/chat.js'; import { AddDynamicVariableAction, IAddDynamicVariableContext } from '../../contrib/chat/browser/contrib/chatDynamicVariables.js'; import { IChatAgentHistoryEntry, IChatAgentImplementation, IChatAgentRequest, IChatAgentService } from '../../contrib/chat/common/chatAgents.js'; +import { ICustomAgentQueryOptions, IPromptsService } from '../../contrib/chat/common/promptSyntax/service/promptsService.js'; import { IChatEditingService, IChatRelatedFileProviderMetadata } from '../../contrib/chat/common/chatEditingService.js'; import { IChatModel } from '../../contrib/chat/common/chatModel.js'; import { ChatRequestAgentPart } from '../../contrib/chat/common/chatParserTypes.js'; @@ -96,6 +97,9 @@ 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 _pendingProgress = new Map void; chatSession: IChatModel | undefined }>(); private readonly _proxy: ExtHostChatAgentsShape2; @@ -115,6 +119,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA @ILogService private readonly _logService: ILogService, @IExtensionService private readonly _extensionService: IExtensionService, @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, + @IPromptsService private readonly _promptsService: IPromptsService, ) { super(); this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostChatAgents2); @@ -427,6 +432,46 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA $unregisterRelatedFilesProvider(handle: number): void { this._chatRelatedFilesProviders.deleteAndDispose(handle); } + + async $registerCustomAgentsProvider(handle: number, 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}`); + return; + } + + const emitter = new Emitter(); + this._customAgentsProviderEmitters.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) { + return undefined; + } + // Convert UriComponents to URI + return agents.map(agent => ({ + ...agent, + uri: URI.revive(agent.uri) + })); + } + }); + + this._customAgentsProviders.set(handle, disposable); + } + + $unregisterCustomAgentsProvider(handle: number): void { + this._customAgentsProviders.deleteAndDispose(handle); + this._customAgentsProviderEmitters.deleteAndDispose(handle); + } + + $onDidChangeCustomAgents(handle: number): void { + const emitter = this._customAgentsProviderEmitters.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 a976c20189f..2c749c62843 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1541,6 +1541,10 @@ 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); + }, }; // namespace: lm @@ -1942,7 +1946,8 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I McpStdioServerDefinition: extHostTypes.McpStdioServerDefinition, McpStdioServerDefinition2: extHostTypes.McpStdioServerDefinition, McpToolAvailability: extHostTypes.McpToolAvailability, - SettingsSearchResultKind: extHostTypes.SettingsSearchResultKind + SettingsSearchResultKind: extHostTypes.SettingsSearchResultKind, + CustomAgentTarget: extHostTypes.CustomAgentTarget, }; }; } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 94b0a860bcc..3aef5d888a8 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -65,6 +65,7 @@ import { IChatRequestVariableValue } from '../../contrib/chat/common/chatVariabl 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/languageModelToolsService.js'; +import { ICustomAgentQueryOptions, IExternalCustomAgent } 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'; @@ -1393,6 +1394,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; $registerAgentCompletionsProvider(handle: number, id: string, triggerCharacters: string[]): void; $unregisterAgentCompletionsProvider(handle: number, id: string): void; $updateAgent(handle: number, metadataUpdate: IExtensionChatAgentMetadata): void; @@ -1457,6 +1461,7 @@ export interface ExtHostChatAgentsShape2 { $releaseSession(sessionId: string): 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>; $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 39a2ffea309..15df4fdc49d 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -34,6 +34,7 @@ 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'; export class ChatAgentResponseStream { @@ -395,6 +396,9 @@ 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 readonly _sessionDisposables: DisposableMap = this._register(new DisposableMap()); private readonly _completionDisposables: DisposableMap = this._register(new DisposableMap()); @@ -472,6 +476,28 @@ 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); + + const disposables = new DisposableStore(); + + // Listen to provider change events and notify main thread + if (provider.onDidChangeCustomAgents) { + disposables.add(provider.onDidChangeCustomAgents(() => { + this._proxy.$onDidChangeCustomAgents(handle); + })); + } + + disposables.add(toDisposable(() => { + this._customAgentsProviders.delete(handle); + this._proxy.$unregisterCustomAgentsProvider(handle); + })); + + return disposables; + } + async $provideRelatedFiles(handle: number, request: IChatRequestDraft, token: CancellationToken): Promise[] | undefined> { const provider = this._relatedFilesProviders.get(handle); if (!provider) { @@ -482,6 +508,15 @@ 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); + if (!providerData) { + return Promise.resolve(undefined); + } + + return await providerData.provider.provideCustomAgents(options, token) ?? undefined; + } + async $detectChatParticipant(handle: number, requestDto: Dto, context: { history: IChatAgentHistoryEntryDto[] }, options: { location: ChatAgentLocation; participants?: vscode.ChatParticipantMetadata[] }, token: CancellationToken): Promise { const detector = this._participantDetectionProviders.get(handle); if (!detector) { diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 3f65deae449..51d171661f6 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3492,6 +3492,11 @@ export enum ChatErrorLevel { Error = 2 } +export enum CustomAgentTarget { + GitHubCopilot = 'github-copilot', + VSCode = 'vscode', +} + export class LanguageModelChatMessage implements vscode.LanguageModelChatMessage { static User(content: string | (LanguageModelTextPart | LanguageModelToolResultPart | LanguageModelToolCallPart | LanguageModelDataPart)[], name?: string): LanguageModelChatMessage { diff --git a/src/vs/workbench/contrib/chat/browser/modelPicker/modePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/modelPicker/modePickerActionItem.ts index 5b312ad32a0..4955a2182d3 100644 --- a/src/vs/workbench/contrib/chat/browser/modelPicker/modePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/modelPicker/modePickerActionItem.ts @@ -27,7 +27,7 @@ import { IProductService } from '../../../../../platform/product/common/productS import { IChatAgentService } from '../../common/chatAgents.js'; import { ChatMode, IChatMode, IChatModeService } from '../../common/chatModes.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common/constants.js'; -import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { ExtensionAgentSourceType, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { getOpenChatActionIdForMode } from '../actions/chatActions.js'; import { IToggleChatModeArgs, ToggleAgentModeActionId } from '../actions/chatExecuteActions.js'; @@ -104,7 +104,7 @@ export class ModePickerActionItem extends ActionWidgetDropdownActionViewItem { const otherBuiltinModes = modes.builtin.filter(mode => mode.id !== ChatMode.Agent.id); const customModes = groupBy( modes.custom, - mode => mode.source?.storage === PromptsStorage.extension && mode.source.extensionId.value === productService.defaultChatAgent?.chatExtensionId ? + mode => mode.source?.storage === PromptsStorage.extension && mode.source.extensionId.value === productService.defaultChatAgent?.chatExtensionId && mode.source.type === ExtensionAgentSourceType.contribution ? 'builtin' : 'custom'); const customBuiltinModeActions = customModes.builtin?.map(mode => { diff --git a/src/vs/workbench/contrib/chat/common/chatModes.ts b/src/vs/workbench/contrib/chat/common/chatModes.ts index bb90dfc9b44..463e05f6a65 100644 --- a/src/vs/workbench/contrib/chat/common/chatModes.ts +++ b/src/vs/workbench/contrib/chat/common/chatModes.ts @@ -20,7 +20,7 @@ import { IChatAgentService } from './chatAgents.js'; import { ChatContextKeys } from './chatContextKeys.js'; import { ChatConfiguration, ChatModeKind } from './constants.js'; import { IHandOff } from './promptSyntax/promptFileParser.js'; -import { IAgentSource, ICustomAgent, IPromptsService, PromptsStorage } from './promptSyntax/service/promptsService.js'; +import { ExtensionAgentSourceType, IAgentSource, ICustomAgent, IPromptsService, PromptsStorage } from './promptSyntax/service/promptsService.js'; export const IChatModeService = createDecorator('chatModeService'); export interface IChatModeService { @@ -419,7 +419,7 @@ export class CustomChatMode implements IChatMode { } type IChatModeSourceData = - | { readonly storage: PromptsStorage.extension; readonly extensionId: string } + | { readonly storage: PromptsStorage.extension; readonly extensionId: string; type?: ExtensionAgentSourceType } | { readonly storage: PromptsStorage.local | PromptsStorage.user }; function isChatModeSourceData(value: unknown): value is IChatModeSourceData { @@ -438,7 +438,7 @@ function serializeChatModeSource(source: IAgentSource | undefined): IChatModeSou return undefined; } if (source.storage === PromptsStorage.extension) { - return { storage: PromptsStorage.extension, extensionId: source.extensionId.value }; + return { storage: PromptsStorage.extension, extensionId: source.extensionId.value, type: source.type }; } return { storage: source.storage }; } @@ -448,7 +448,7 @@ function reviveChatModeSource(data: IChatModeSourceData | undefined): IAgentSour return undefined; } if (data.storage === PromptsStorage.extension) { - return { storage: PromptsStorage.extension, extensionId: new ExtensionIdentifier(data.extensionId) }; + return { storage: PromptsStorage.extension, extensionId: new ExtensionIdentifier(data.extensionId), type: data.type ?? ExtensionAgentSourceType.contribution }; } return { storage: data.storage }; } 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 ebc7fa55d99..c7023ba9e57 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -15,6 +15,44 @@ import { PromptsType } from '../promptTypes.js'; import { IHandOff, ParsedPromptFile } from '../promptFileParser.js'; import { ResourceSet } from '../../../../../../base/common/map.js'; +/** + * Target environment for custom agents. + */ +export enum CustomAgentTarget { + GitHubCopilot = 'github-copilot', + VSCode = 'vscode', +} + +/** + * Options for querying custom agents. + */ +export interface ICustomAgentQueryOptions { + /** + * Filter agents by target environment. + */ + readonly target?: CustomAgentTarget; +} + +/** + * Represents a custom agent 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; + + /** + * The URI to the agent or prompt resource file. + */ + readonly uri: URI; +} + /** * Provides prompt services. */ @@ -29,6 +67,14 @@ export enum PromptsStorage { extension = 'extension' } +/** + * The type of source for extension agents. + */ +export enum ExtensionAgentSourceType { + contribution = 'contribution', + provider = 'provider', +} + /** * Represents a prompt path with its type. * This is used for both prompt files and prompt source folders. @@ -67,6 +113,7 @@ export interface IExtensionPromptPath extends IPromptPathBase { readonly extension: IExtensionDescription; readonly name: string; readonly description: string; + readonly source: ExtensionAgentSourceType; } export interface ILocalPromptPath extends IPromptPathBase { readonly storage: PromptsStorage.local; @@ -78,6 +125,7 @@ export interface IUserPromptPath extends IPromptPathBase { export type IAgentSource = { readonly storage: PromptsStorage.extension; readonly extensionId: ExtensionIdentifier; + readonly type: ExtensionAgentSourceType; } | { readonly storage: PromptsStorage.local | PromptsStorage.user; }; @@ -270,6 +318,18 @@ 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. + * @param extension The extension registering the provider. + * @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; + }): IDisposable; + /** * Gets list of claude skills files. */ 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 34f65d999af..fe481db1155 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -6,7 +6,7 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { CancellationError } from '../../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; -import { Disposable, IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap, ResourceSet } from '../../../../../../base/common/map.js'; import { dirname, isEqual } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -17,6 +17,7 @@ import { localize } from '../../../../../../nls.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IExtensionDescription } from '../../../../../../platform/extensions/common/extensions.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; +import { IExtensionService } from '../../../../../services/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; @@ -29,10 +30,9 @@ 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, IClaudeSkill, IUserPromptPath, PromptsStorage } from './promptsService.js'; +import { IAgentInstructions, IAgentSource, IChatPromptSlashCommand, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPromptPath, IPromptsService, IClaudeSkill, IUserPromptPath, PromptsStorage, ICustomAgentQueryOptions, IExternalCustomAgent, ExtensionAgentSourceType } from './promptsService.js'; import { Delayer } from '../../../../../../base/common/async.js'; import { Schemas } from '../../../../../../base/common/network.js'; -import { IExtensionService } from '../../../../../services/extensions/common/extensions.js'; /** * Provides prompt services. @@ -154,16 +154,107 @@ export class PromptsService extends Disposable implements IPromptsService { const prompts = await Promise.all([ this.fileLocator.listFiles(type, PromptsStorage.user, token).then(uris => uris.map(uri => ({ uri, storage: PromptsStorage.user, type } satisfies IUserPromptPath))), this.fileLocator.listFiles(type, PromptsStorage.local, token).then(uris => uris.map(uri => ({ uri, storage: PromptsStorage.local, type } satisfies ILocalPromptPath))), - this.getExtensionContributions(type) + this.getExtensionPromptFiles(type, token), ]); return [...prompts.flat()]; } + /** + * Registry of CustomAgentsProvider instances. Extensions can register providers via the proposed API. + */ + private readonly customAgentsProviders: Array<{ + extension: IExtensionDescription; + onDidChangeCustomAgents?: Event; + provideCustomAgents: (options: ICustomAgentQueryOptions, 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(). + */ + public registerCustomAgentsProvider(extension: IExtensionDescription, provider: { + onDidChangeCustomAgents?: Event; + provideCustomAgents: (options: ICustomAgentQueryOptions, token: CancellationToken) => Promise; + }): IDisposable { + const providerEntry = { extension, ...provider }; + this.customAgentsProviders.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(); + })); + } + + // Invalidate agent cache when providers change + this.cachedFileLocations[PromptsType.agent] = undefined; + this.cachedCustomAgents.refresh(); + + disposables.add({ + dispose: () => { + const index = this.customAgentsProviders.findIndex((p) => p === providerEntry); + if (index >= 0) { + this.customAgentsProviders.splice(index, 1); + this.cachedFileLocations[PromptsType.agent] = undefined; + this.cachedCustomAgents.refresh(); + } + } + }); + + return disposables; + } + + private async listCustomAgentsFromProvider(token: CancellationToken): Promise { + const result: IPromptPath[] = []; + + if (this.customAgentsProviders.length === 0) { + return result; + } + + // Collect agents from all providers + for (const providerEntry of this.customAgentsProviders) { + try { + const agents = await providerEntry.provideCustomAgents({}, token); + if (!agents || token.isCancellationRequested) { + continue; + } + + for (const agent of agents) { + try { + await this.filesConfigService.updateReadonly(agent.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); + } + + result.push({ + uri: agent.uri, + name: agent.name, + description: agent.description, + storage: PromptsStorage.extension, + type: PromptsType.agent, + 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)); + } + } + + return result; + } + + + public async listPromptFilesForStorage(type: PromptsType, storage: PromptsStorage, token: CancellationToken): Promise { switch (storage) { case PromptsStorage.extension: - return this.getExtensionContributions(type); + return this.getExtensionPromptFiles(type, token); case PromptsStorage.local: return this.fileLocator.listFiles(type, PromptsStorage.local, token).then(uris => uris.map(uri => ({ uri, storage: PromptsStorage.local, type } satisfies ILocalPromptPath))); case PromptsStorage.user: @@ -173,9 +264,14 @@ export class PromptsService extends Disposable implements IPromptsService { } } - private async getExtensionContributions(type: PromptsType): Promise { + private async getExtensionPromptFiles(type: PromptsType, token: CancellationToken): Promise { await this.extensionService.whenInstalledExtensionsRegistered(); - return Promise.all(this.contributedFiles[type].values()); + const contributedFiles = await Promise.all(this.contributedFiles[type].values()); + if (type === PromptsType.agent) { + const providerAgents = await this.listCustomAgentsFromProvider(token); + return [...contributedFiles, ...providerAgents]; + } + return contributedFiles; } public getSourceFolders(type: PromptsType): readonly IPromptPath[] { @@ -359,7 +455,7 @@ export class PromptsService extends Disposable implements IPromptsService { const msg = e instanceof Error ? e.message : String(e); this.logger.error(`[registerContributedFile] Failed to make prompt file readonly: ${uri}`, msg); } - return { uri, name, description, storage: PromptsStorage.extension, type, extension } satisfies IExtensionPromptPath; + return { uri, name, description, storage: PromptsStorage.extension, type, extension, source: ExtensionAgentSourceType.contribution } satisfies IExtensionPromptPath; })(); bucket.set(uri, entryPromise); @@ -578,7 +674,8 @@ namespace IAgentSource { if (promptPath.storage === PromptsStorage.extension) { return { storage: PromptsStorage.extension, - extensionId: promptPath.extension.identifier + extensionId: promptPath.extension.identifier, + type: promptPath.source }; } else { return { diff --git a/src/vs/workbench/contrib/chat/test/common/mockPromptsService.ts b/src/vs/workbench/contrib/chat/test/common/mockPromptsService.ts index 6b56afd3aa0..e4caaf2ff4d 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockPromptsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/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 { IClaudeSkill, ICustomAgent, IPromptPath, IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { IClaudeSkill, ICustomAgent, ICustomAgentQueryOptions, IExternalCustomAgent, IPromptPath, IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { ResourceSet } from '../../../../../base/common/map.js'; export class MockPromptsService implements IPromptsService { @@ -53,6 +53,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.'); } findClaudeSkills(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 d883da901e7..f9e0ef2b007 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 { ICustomAgent, IPromptsService, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; +import { ExtensionAgentSourceType, ICustomAgent, ICustomAgentQueryOptions, 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'; @@ -1071,6 +1071,58 @@ suite('PromptsService', () => { assert.strictEqual(actual[0].type, PromptsType.instructions); registered.dispose(); }); + + test('Custom agent provider', async () => { + const agentUri = URI.parse('file://extensions/my-extension/myAgent.agent.md'); + const extension = { + identifier: { value: 'test.my-extension' }, + enabledApiProposals: ['chatParticipantPrivate'] + } as unknown as IExtensionDescription; + + // Mock the agent file content + await mockFiles(fileService, [ + { + path: agentUri.path, + contents: [ + '---', + 'description: \'My custom agent from provider\'', + 'tools: [ tool1, tool2 ]', + '---', + 'I am a custom agent from a provider.', + ] + } + ]); + + const provider = { + provideCustomAgents: async (_options: ICustomAgentQueryOptions, _token: CancellationToken) => { + return [ + { + name: 'myAgent', + description: 'My custom agent from provider', + uri: agentUri + } + ]; + } + }; + + const registered = service.registerCustomAgentsProvider(extension, provider); + + const actual = await service.getCustomAgents(CancellationToken.None); + assert.strictEqual(actual.length, 1); + assert.strictEqual(actual[0].name, 'myAgent'); + 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(); + + // After disposal, the agent should no longer be listed + const actualAfterDispose = await service.getCustomAgents(CancellationToken.None); + assert.strictEqual(actualAfterDispose.length, 0); + }); }); suite('findClaudeSkills', () => { diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index d477cb91b4a..9407b3a5814 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -314,4 +314,73 @@ 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; + } + + /** + * Target environment for custom agents. + */ + export enum CustomAgentTarget { + GitHubCopilot = 'github-copilot', + VSCode = 'vscode', + } + + /** + * Options for querying custom agents. + */ + export interface CustomAgentQueryOptions { + /** + * Filter agents by target environment. + */ + readonly target?: CustomAgentTarget; + } + + /** + * 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. + */ + 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 }