Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,33 @@
import { localize } from '../../../../../nls.js';
import { IWorkbenchContribution } from '../../../../common/contributions.js';
import * as extensionsRegistry from '../../../../services/extensions/common/extensionsRegistry.js';
import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js';
import { ExtensionIdentifier, IExtensionDescription } from '../../../../../platform/extensions/common/extensions.js';
import { joinPath, isEqualOrParent } from '../../../../../base/common/resources.js';
import { IPromptsService } from './service/promptsService.js';
import { ExtensionAgentSourceType, IPromptsService } from './service/promptsService.js';
import { PromptsType } from './promptTypes.js';
import { DisposableMap } from '../../../../../base/common/lifecycle.js';
import { IFileService } from '../../../../../platform/files/common/files.js';
import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js';
import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js';
import { URI } from '../../../../../base/common/uri.js';

type PathType = 'extension' | 'storageUri';

interface IRawChatFileContribution {
readonly name: string;
readonly path: string;
readonly description?: string; // reserved for future use
readonly pathType?: PathType;
}

interface IRawChatFolderContribution {
readonly path: string;
readonly pathType?: PathType;
readonly external?: boolean;
}

type ChatContributionPoint = 'chatPromptFiles' | 'chatInstructions' | 'chatAgents';
type ChatFolderContributionPoint = 'chatPromptFileFolders' | 'chatInstructionFolders' | 'chatAgentFolders';

function registerChatFilesExtensionPoint(point: ChatContributionPoint) {
return extensionsRegistry.ExtensionsRegistry.registerExtensionPoint<IRawChatFileContribution[]>({
Expand All @@ -33,7 +47,8 @@ function registerChatFilesExtensionPoint(point: ChatContributionPoint) {
body: {
name: 'exampleName',
path: './relative/path/to/file.md',
description: 'Optional description'
description: 'Optional description',
pathType: 'extension'
}
}],
required: ['name', 'path'],
Expand All @@ -44,12 +59,56 @@ function registerChatFilesExtensionPoint(point: ChatContributionPoint) {
pattern: '^[\\w.-]+$'
},
path: {
description: localize('chatContribution.property.path', 'Path to the file relative to the extension root.'),
description: localize('chatContribution.property.path', 'Path to the file relative to the extension root or storageUri.'),
type: 'string'
},
description: {
description: localize('chatContribution.property.description', '(Optional) Description of the file.'),
type: 'string'
},
pathType: {
description: localize('chatContribution.property.pathType', '(Optional) Type of path: "extension" (default, relative to extension root) or "storageUri" (relative to workspace storage).'),
type: 'string',
enum: ['extension', 'storageUri'],
default: 'extension'
}
}
}
}
});
}

function registerChatFoldersExtensionPoint(point: ChatFolderContributionPoint, fileTypeName: string, defaultPath: string, fileExtension: string) {
return extensionsRegistry.ExtensionsRegistry.registerExtensionPoint<IRawChatFolderContribution[]>({
extensionPoint: point,
jsonSchema: {
description: localize('chatFolders.schema.description', 'Contributes directories containing {0} files for chat.', fileTypeName),
type: 'array',
items: {
additionalProperties: false,
type: 'object',
defaultSnippets: [{
body: {
path: defaultPath,
pathType: 'extension'
}
}],
required: ['path'],
properties: {
path: {
description: localize('chatFolders.property.path', 'Path to the directory containing {0} files relative to the extension root or storageUri.', fileExtension),
type: 'string'
},
pathType: {
description: localize('chatFolders.property.pathType', '(Optional) Type of path: "extension" (default, relative to extension root) or "storageUri" (relative to workspace storage).'),
type: 'string',
enum: ['extension', 'storageUri'],
default: 'extension'
},
external: {
description: localize('chatFolders.property.external', '(Optional) If true, agents from this folder will not appear in the builtin section but will be shown in the Configure Custom Agents menu. Requires chatParticipantPrivate proposal.'),
type: 'boolean',
default: false
}
}
}
Expand All @@ -60,6 +119,9 @@ function registerChatFilesExtensionPoint(point: ChatContributionPoint) {
const epPrompt = registerChatFilesExtensionPoint('chatPromptFiles');
const epInstructions = registerChatFilesExtensionPoint('chatInstructions');
const epAgents = registerChatFilesExtensionPoint('chatAgents');
const epPromptFileFolders = registerChatFoldersExtensionPoint('chatPromptFileFolders', 'prompt', './prompts', '.prompt.md');
const epInstructionFolders = registerChatFoldersExtensionPoint('chatInstructionFolders', 'instruction', './instructions', '.instructions.md');
const epAgentFolders = registerChatFoldersExtensionPoint('chatAgentFolders', 'agent', './agents', '.agent.md');

function pointToType(contributionPoint: ChatContributionPoint): PromptsType {
switch (contributionPoint) {
Expand All @@ -69,8 +131,25 @@ function pointToType(contributionPoint: ChatContributionPoint): PromptsType {
}
}

function key(extensionId: ExtensionIdentifier, type: PromptsType, name: string) {
return `${extensionId.value}/${type}/${name}`;
function folderPointToType(contributionPoint: ChatFolderContributionPoint): PromptsType {
switch (contributionPoint) {
case 'chatPromptFileFolders': return PromptsType.prompt;
case 'chatInstructionFolders': return PromptsType.instructions;
case 'chatAgentFolders': return PromptsType.agent;
}
}

function getFileExtension(type: PromptsType): string {
switch (type) {
case PromptsType.prompt: return '.prompt.md';
case PromptsType.instructions: return '.instructions.md';
case PromptsType.agent: return '.agent.md';
}
}

function key(extensionId: ExtensionIdentifier, type: PromptsType, name: string, isPrefix?: boolean): string {
const base = `${extensionId.value}/${type}/${name}`;
return isPrefix ? `${base}/` : base;
}

export class ChatPromptFilesExtensionPointHandler implements IWorkbenchContribution {
Expand All @@ -80,10 +159,33 @@ export class ChatPromptFilesExtensionPointHandler implements IWorkbenchContribut

constructor(
@IPromptsService private readonly promptsService: IPromptsService,
@IFileService private readonly fileService: IFileService,
@IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService,
@IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService,
) {
this.handle(epPrompt, 'chatPromptFiles');
this.handle(epInstructions, 'chatInstructions');
this.handle(epAgents, 'chatAgents');
this.handleFolders(epPromptFileFolders, 'chatPromptFileFolders');
this.handleFolders(epInstructionFolders, 'chatInstructionFolders');
this.handleFolders(epAgentFolders, 'chatAgentFolders');
}

private resolvePathUri(path: string, pathType: PathType | undefined, extension: IExtensionDescription): URI {
const effectivePathType = pathType || 'extension';

if (effectivePathType === 'storageUri') {
// Construct path: workspaceStorageHome/<workspace-id>/<extension-id>/<path>
const workspaceId = this.workspaceContextService.getWorkspace().id;
const extensionStorageUri = joinPath(
this.environmentService.workspaceStorageHome,
workspaceId,
extension.identifier.value
);
return joinPath(extensionStorageUri, path);
} else {
return joinPath(extension.extensionLocation, path);
}
}

private handle(extensionPoint: extensionsRegistry.IExtensionPoint<IRawChatFileContribution[]>, contributionPoint: ChatContributionPoint) {
Expand All @@ -103,13 +205,20 @@ export class ChatPromptFilesExtensionPointHandler implements IWorkbenchContribut
ext.collector.error(localize('extension.missing.description', "Extension '{0}' cannot register {1} entry '{2}' without description.", ext.description.identifier.value, contributionPoint, raw.name));
continue;
}
const fileUri = joinPath(ext.description.extensionLocation, raw.path);
if (!isEqualOrParent(fileUri, ext.description.extensionLocation)) {

// Handle pathType resolution
const pathType = raw.pathType || 'extension';
const fileUri = this.resolvePathUri(raw.path, raw.pathType, ext.description);
const baseUri = pathType === 'storageUri' ? undefined : ext.description.extensionLocation;

// Only validate extension-relative paths
if (baseUri && !isEqualOrParent(fileUri, baseUri)) {
ext.collector.error(localize('extension.invalid.path', "Extension '{0}' {1} entry '{2}' path resolves outside the extension.", ext.description.identifier.value, contributionPoint, raw.name));
continue;
}
try {
const d = this.promptsService.registerContributedFile(type, raw.name, raw.description, fileUri, ext.description);
const description = raw.description || '';
const d = this.promptsService.registerContributedFile(type, raw.name, description, fileUri, ext.description);
this.registrations.set(key(ext.description.identifier, type, raw.name), d);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
Expand All @@ -125,4 +234,86 @@ export class ChatPromptFilesExtensionPointHandler implements IWorkbenchContribut
}
});
}

private handleFolders(extensionPoint: extensionsRegistry.IExtensionPoint<IRawChatFolderContribution[]>, contributionPoint: ChatFolderContributionPoint) {
extensionPoint.setHandler((_extensions, delta) => {
for (const ext of delta.added) {
const type = folderPointToType(contributionPoint);
const fileExtension = getFileExtension(type);
for (const raw of ext.value) {
if (!raw.path) {
ext.collector.error(localize('extension.missing.folder.path', "Extension '{0}' cannot register {1} entry without path.", ext.description.identifier.value, contributionPoint));
continue;
}

// Handle pathType resolution and scan directory
const pathType = raw.pathType || 'extension';
const dirUri = this.resolvePathUri(raw.path, raw.pathType, ext.description);
const baseUri = pathType === 'storageUri' ? undefined : ext.description.extensionLocation;

// Only validate extension-relative paths
if (baseUri && !isEqualOrParent(dirUri, baseUri)) {
ext.collector.error(localize('extension.invalid.folder.path', "Extension '{0}' {1} entry path '{2}' resolves outside the extension.", ext.description.identifier.value, contributionPoint, raw.path));
continue;
}

// Scan directory asynchronously
(async () => {
try {
// Check if directory exists
const stat = await this.fileService.resolve(dirUri);
if (!stat.isDirectory) {
ext.collector.error(localize('extension.not.folder', "Extension '{0}' {1} entry path '{2}' is not a directory.", ext.description.identifier.value, contributionPoint, raw.path));
return;
}

// Check if the folder prefix still exists in registrations (extension not removed)
const folderPrefix = key(ext.description.identifier, type, raw.path, true);
const extensionStillActive = Array.from(this.registrations.keys()).some(k => k.startsWith(folderPrefix.slice(0, folderPrefix.lastIndexOf('/', folderPrefix.length - 2) + 1)));
if (!extensionStillActive) {
// Extension was removed while we were scanning
return;
}

// Scan directory for files with matching extension
if (stat.children) {
for (const child of stat.children) {
if (child.isFile && child.name.endsWith(fileExtension)) {
const fileName = child.name;
const name = fileName.slice(0, -fileExtension.length);

try {
const description = localize('extension.folder.file.description', "Contributed from folder: {0}", raw.path);
const uniqueName = `${raw.path}/${name}`;
const sourceType = raw.external === true ? ExtensionAgentSourceType.externalContribution : ExtensionAgentSourceType.contribution;
const d = this.promptsService.registerContributedFile(type, uniqueName, description, child.resource, ext.description, sourceType);
this.registrations.set(key(ext.description.identifier, type, uniqueName), d);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
ext.collector.error(localize('extension.folder.file.registration.failed', "Failed to register file '{0}' from {1} folder '{2}': {3}", fileName, contributionPoint, raw.path, msg));
}
}
}
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
ext.collector.error(localize('extension.folder.scan.failed', "Failed to scan {0} folder '{1}': {2}", contributionPoint, raw.path, msg));
}
})();
}
}
for (const ext of delta.removed) {
const type = folderPointToType(contributionPoint);
for (const raw of ext.value) {
// Remove all files registered from this folder using exact prefix matching
const folderPrefix = key(ext.description.identifier, type, raw.path, true);
for (const [regKey,] of this.registrations) {
if (regKey.startsWith(folderPrefix)) {
this.registrations.deleteAndDispose(regKey);
}
}
}
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export enum PromptsStorage {
export enum ExtensionAgentSourceType {
contribution = 'contribution',
provider = 'provider',
externalContribution = 'externalContribution',
}

/**
Expand Down Expand Up @@ -281,7 +282,7 @@ export interface IPromptsService extends IDisposable {
* Internal: register a contributed file. Returns a disposable that removes the contribution.
* Not intended for extension authors; used by contribution point handler.
*/
registerContributedFile(type: PromptsType, name: string, description: string, uri: URI, extension: IExtensionDescription): IDisposable;
registerContributedFile(type: PromptsType, name: string, description: string, uri: URI, extension: IExtensionDescription, sourceType?: ExtensionAgentSourceType): IDisposable;


getPromptLocationLabel(promptPath: IPromptPath): string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,7 @@ export class PromptsService extends Disposable implements IPromptsService {
return new PromptFileParser().parse(uri, fileContent.value.toString());
}

public registerContributedFile(type: PromptsType, name: string, description: string, uri: URI, extension: IExtensionDescription) {
public registerContributedFile(type: PromptsType, name: string, description: string, uri: URI, extension: IExtensionDescription, sourceType?: ExtensionAgentSourceType) {
const bucket = this.contributedFiles[type];
if (bucket.has(uri)) {
// keep first registration per extension (handler filters duplicates per extension already)
Expand All @@ -455,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, source: ExtensionAgentSourceType.contribution } satisfies IExtensionPromptPath;
return { uri, name, description, storage: PromptsStorage.extension, type, extension, source: sourceType ?? ExtensionAgentSourceType.contribution } satisfies IExtensionPromptPath;
})();
bucket.set(uri, entryPromise);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export class MockPromptsService implements IPromptsService {
parse(_uri: URI, _type: any, _token: CancellationToken): Promise<any> { throw new Error('Not implemented'); }
parseNew(_uri: URI, _token: CancellationToken): Promise<any> { throw new Error('Not implemented'); }
getParsedPromptFile(textModel: ITextModel): ParsedPromptFile { throw new Error('Not implemented'); }
registerContributedFile(type: PromptsType, name: string, description: string, uri: URI, extension: IExtensionDescription): IDisposable { throw new Error('Not implemented'); }
registerContributedFile(type: PromptsType, name: string, description: string, uri: URI, extension: IExtensionDescription, sourceType?: any): IDisposable { throw new Error('Not implemented'); }
getPromptLocationLabel(promptPath: IPromptPath): string { throw new Error('Not implemented'); }
findAgentMDsInWorkspace(token: CancellationToken): Promise<URI[]> { throw new Error('Not implemented'); }
listAgentMDs(token: CancellationToken): Promise<URI[]> { throw new Error('Not implemented'); }
Expand Down
Loading