move prompt file parser usages to IPromptsService (#251298)

This commit is contained in:
Martin Aeschlimann
2025-06-12 20:52:23 +02:00
committed by GitHub
parent 0ab638ea0f
commit 086501e7d7
13 changed files with 95 additions and 188 deletions
@@ -10,7 +10,7 @@ import { IRange } from '../../../../editor/common/core/range.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { IChatRequestFileEntry, IChatRequestVariableEntry, isPromptFileVariableEntry, toPromptFileVariableEntry } from '../common/chatVariableEntries.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { ChatPromptAttachmentsCollection } from './chatAttachmentModel/chatPromptAttachmentsCollection.js';
import { ChatPromptAttachmentsCollection } from './chatAttachmentModel/chatPromptAttachments.js';
import { IFileService } from '../../../../platform/files/common/files.js';
import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
import { ISharedWebContentExtractorService } from '../../../../platform/webContentExtractor/common/webContentExtractor.js';
@@ -1,117 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { URI } from '../../../../../base/common/uri.js';
import { PromptParser } from '../../common/promptSyntax/parsers/promptParser.js';
import { BasePromptParser } from '../../common/promptSyntax/parsers/basePromptParser.js';
import { IPromptContentsProvider } from '../../common/promptSyntax/contentProviders/types.js';
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
import { Disposable } from '../../../../../base/common/lifecycle.js';
/**
* Type for a generic prompt parser object.
*/
type TPromptParser = BasePromptParser<IPromptContentsProvider>;
/**
* Model for a single chat prompt instructions attachment.
*/
export class ChatPromptAttachmentModel extends Disposable {
/**
* Private reference of the underlying prompt instructions
* reference instance.
*/
private readonly _reference: TPromptParser;
/**
* Get the prompt instructions reference instance.
*/
public get reference(): TPromptParser {
return this._reference;
}
/**
* Get `URI` for the main reference and `URI`s of all valid child
* references it may contain, including reference of this model itself.
*/
public get references(): readonly URI[] {
const { reference } = this;
const { errorCondition } = reference;
// return no references if the attachment is disabled
// or if this object itself has an error
if (errorCondition) {
return [];
}
// otherwise return `URI` for the main reference and
// all valid child `URI` references it may contain
return [
...reference.allValidReferences.map(ref => ref.uri),
reference.uri,
];
}
/**
* Get list of all tools associated with the prompt.
*
* Note! This property returns pont-in-time state of the tools metadata
* and does not take into account if the prompt or its nested child
* references are still being resolved. Please use the {@link settled}
* or {@link allSettled} properties if you need to retrieve the final
* list of the tools available.
*/
public get toolsMetadata(): readonly string[] | null {
return this.reference.allToolsMetadata;
}
/**
* Promise that resolves when the prompt is fully parsed,
* including all its possible nested child references.
*/
public get allSettled(): Promise<TPromptParser> {
return this.reference.allSettled();
}
/**
* Get the top-level error of the prompt instructions
* reference, if any.
*/
public get topError() {
return this.reference.topError;
}
constructor(
public readonly uri: URI,
private readonly _onUpdate: () => void,
@IInstantiationService private readonly instantiationService: IInstantiationService,
) {
super();
this._reference = this._register(
this.instantiationService.createInstance(
PromptParser,
this.uri,
// in this case we know that the attached file must have been a
// prompt file, hence we pass the `allowNonPromptFiles` option
// to the provider to allow for non-prompt files to be attached
{ allowNonPromptFiles: true },
)
);
this._register(this._reference.onUpdate(() => this._onUpdate()));
}
/**
* Start resolving the prompt instructions reference and child references
* that it may contain.
*/
public resolve(): this {
this._reference.start();
return this;
}
}
@@ -3,19 +3,19 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { CancelablePromise, createCancelablePromise } from '../../../../../base/common/async.js';
import { Emitter } from '../../../../../base/common/event.js';
import { Disposable } from '../../../../../base/common/lifecycle.js';
import { ResourceMap } from '../../../../../base/common/map.js';
import { URI } from '../../../../../base/common/uri.js';
import { ILanguageService } from '../../../../../editor/common/languages/language.js';
import { IModelService } from '../../../../../editor/common/services/model.js';
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
import { IChatRequestVariableEntry, IPromptFileVariableEntry, toPromptFileVariableEntry } from '../../common/chatVariableEntries.js';
import { ChatPromptAttachmentModel } from './chatPromptAttachmentModel.js';
import { IPromptParserResult, IPromptsService } from '../../common/promptSyntax/service/promptsService.js';
/**
* Model for a collection of prompt instruction attachments.
* See {@linkcode ChatPromptAttachmentModel} for individual attachment.
* Starts
*/
export class ChatPromptAttachmentsCollection extends Disposable {
/**
@@ -24,29 +24,38 @@ export class ChatPromptAttachmentsCollection extends Disposable {
* See {@linkcode onUpdate}.
*/
protected _onUpdate = this._register(new Emitter<IPromptFileVariableEntry>());
/**
* Subscribe to the `onUpdate` event.
*/
public onUpdate = this._onUpdate.event;
/**
* List of all prompt instruction attachments.
*/
private _attachments: ResourceMap<ChatPromptAttachmentModel> = new ResourceMap<ChatPromptAttachmentModel>();
private _attachments = new ResourceMap<CancelablePromise<IPromptParserResult>>();
constructor(
@ILanguageService private readonly languageService: ILanguageService,
@IModelService private readonly modelService: IModelService,
@IPromptsService private readonly promptsService: IPromptsService,
) {
super();
}
/**
* Check if any of the attachments is a prompt file.
*/
public hasPromptFiles(promptFileLanguageId: string): boolean {
const hasLanguage = ({ uri }: ChatPromptAttachmentModel) => {
const hasLanguage = (uri: URI) => {
const model = this.modelService.getModel(uri);
const languageId = model ? model.getLanguageId() : this.languageService.guessLanguageIdByFilepathOrFirstLine(uri);
return languageId === promptFileLanguageId;
};
for (const child of this._attachments.values()) {
if (hasLanguage(child)) {
for (const uri of this._attachments.keys()) {
if (hasLanguage(uri)) {
return true;
}
}
@@ -58,51 +67,25 @@ export class ChatPromptAttachmentsCollection extends Disposable {
* nested child references of each attachment explicitly attached by user.
*/
public async getAttachments(): Promise<readonly IChatRequestVariableEntry[]> {
await this.allSettled();
const result = [];
const attachments = [...this._attachments.values()];
for (const attachment of attachments) {
const { reference } = attachment;
for (const parseResultPromise of attachments) {
const parseResult = await parseResultPromise;
// the usual URIs list of prompt instructions is `bottom-up`, therefore
// we do the same here - first add all child references of the model
result.push(
...reference.allValidReferences.map((link) => {
return toPromptFileVariableEntry(link.uri, false);
}),
);
for (const uri of parseResult.allValidReferences) {
result.push(toPromptFileVariableEntry(uri, false));
}
// then add the root reference of the model itself
result.push(toPromptFileVariableEntry(reference.uri, true));
result.push(toPromptFileVariableEntry(parseResult.uri, true));
}
return result;
}
/**
* Promise that resolves when parsing of all attached prompt instruction
* files completes, including parsing of all its possible child references.
*/
async allSettled(): Promise<void> {
const attachments = [...this._attachments.values()];
await Promise.allSettled(
attachments.map((attachment) => {
return attachment.allSettled;
}),
);
}
constructor(
@IInstantiationService private readonly instantiationService: IInstantiationService,
@ILanguageService private readonly languageService: ILanguageService,
@IModelService private readonly modelService: IModelService,
) {
super();
}
/**
* Add prompt instruction attachment instances
*/
@@ -115,8 +98,15 @@ export class ChatPromptAttachmentsCollection extends Disposable {
continue;
}
const instruction = this.instantiationService.createInstance(ChatPromptAttachmentModel, uri, () => this._onUpdate.fire(entry));
this._attachments.set(uri, instruction);
const parseResult = createCancelablePromise(token => this.promptsService.parse(uri, token));
parseResult.then(() => {
this._onUpdate.fire(entry);
}).catch((error) => {
// if parsing fails, we still create an attachment model
// to allow the user to see the error and fix it
console.error(`Failed to parse prompt file ${uri.toString()}:`, error);
});
this._attachments.set(uri, parseResult);
}
}
@@ -129,7 +119,7 @@ export class ChatPromptAttachmentsCollection extends Disposable {
const attachment = this._attachments.get(uri);
if (attachment) {
this._attachments.delete(uri);
attachment.dispose();
attachment.cancel();
}
return this;
@@ -140,7 +130,7 @@ export class ChatPromptAttachmentsCollection extends Disposable {
*/
public clear(): this {
for (const attachment of this._attachments.values()) {
attachment.dispose();
attachment.cancel();
}
this._attachments.clear();
@@ -49,7 +49,7 @@ class PromptToolsCodeLensProvider extends Disposable implements CodeLensProvider
const parser = this.promptsService.getSyntaxParserFor(model);
const { header } = await parser
.start()
.start(token)
.settled();
if ((header === undefined) || token.isCancellationRequested) {
@@ -3,6 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { CancellationToken } from '../../../../base/common/cancellation.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { localize } from '../../../../nls.js';
@@ -49,7 +50,7 @@ export class ChatModeService extends Disposable implements IChatModeService {
private async refreshCustomPromptModes(fireChangeEvent?: boolean): Promise<void> {
try {
const modes = await this.promptsService.getCustomChatModes();
const modes = await this.promptsService.getCustomChatModes(CancellationToken.None);
this.latestCustomPromptModes = modes.map(customMode => new CustomChatMode(customMode));
this.hasCustomModes.set(modes.length > 0);
if (fireChangeEvent) {
@@ -179,14 +179,14 @@ export abstract class PromptContentsProviderBase<
/**
* Start producing the prompt contents data.
*/
public start(): this {
public start(token?: CancellationToken): this {
assert(
!this.isDisposed,
'Cannot start contents provider that was already disposed.',
);
// `'full'` means "everything has changed"
this.onContentsChanged('full');
this.onContentsChanged('full', token);
// subscribe to the change event emitted by a child class
this._register(this.onChangeEmitter.event(this.onContentsChanged, this));
@@ -9,6 +9,7 @@ import { ResolveError } from '../../promptFileReferenceErrors.js';
import { IDisposable } from '../../../../../../base/common/lifecycle.js';
import { VSBufferReadableStream } from '../../../../../../base/common/buffer.js';
import { PromptsType } from '../promptTypes.js';
import { CancellationToken } from '../../../../../../base/common/cancellation.js';
/**
* Interface for a prompt contents provider. Prompt contents providers are
@@ -56,7 +57,7 @@ export interface IPromptContentsProvider extends IDisposable {
/**
* Start the contents provider to produce the underlying contents.
*/
start(): this;
start(token?: CancellationToken): this;
/**
* Create a new instance of prompt contents provider.
@@ -42,7 +42,7 @@ export class PromptLinkProvider implements LinkProvider {
// start the parser in case it was not started yet,
// and wait for it to settle to a final result
const { references } = await parser
.start()
.start(token)
.settled();
// validate that the cancellation was not yet requested
@@ -138,7 +138,7 @@ export class PromptPathAutocompletion extends Disposable implements CompletionIt
// start the parser in case it was not started yet,
// and wait for it to settle to a final result
const { references } = await parser
.start()
.start(token)
.settled();
// validate that the cancellation was not yet requested
@@ -39,6 +39,7 @@ import { isPromptOrInstructionsFile } from '../config/promptFileLocations.js';
import { FrontMatterHeader } from '../codecs/base/markdownExtensionsCodec/tokens/frontMatterHeader.js';
import { OpenFailed, NotPromptFile, RecursiveReference, FolderReference, ResolveError } from '../../promptFileReferenceErrors.js';
import { type IPromptContentsProviderOptions, DEFAULT_OPTIONS as CONTENTS_PROVIDER_DEFAULT_OPTIONS } from '../contentProviders/promptContentsProviderBase.js';
import { CancellationToken } from '../../../../../../base/common/cancellation.js';
/**
* Options of the {@link BasePromptParser} class.
@@ -511,7 +512,7 @@ export class BasePromptParser<TContentsProvider extends IPromptContentsProvider>
/**
* Start the prompt parser.
*/
public start(): this {
public start(token?: CancellationToken): this {
// if already started, nothing to do
if (this.started) {
return this;
@@ -525,7 +526,7 @@ export class BasePromptParser<TContentsProvider extends IPromptContentsProvider>
return this;
}
this.promptContentsProvider.start();
this.promptContentsProvider.start(token);
return this;
}
@@ -14,6 +14,7 @@ import { TextModelPromptParser } from '../parsers/textModelPromptParser.js';
import { CancellationToken } from '../../../../../../base/common/cancellation.js';
import { PromptsType } from '../promptTypes.js';
import { createDecorator } from '../../../../../../platform/instantiation/common/instantiation.js';
import { ITopError } from '../parsers/types.js';
/**
* Provides prompt services.
@@ -151,9 +152,7 @@ export interface IPromptsService extends IDisposable {
* Get a prompt syntax parser for the provided text model.
* See {@link TextModelPromptParser} for more info on the parser API.
*/
getSyntaxParserFor(
model: ITextModel,
): TSharedPrompt & { isDisposed: false };
getSyntaxParserFor(model: ITextModel): TSharedPrompt & { isDisposed: false };
/**
* List all available prompt files.
@@ -185,9 +184,7 @@ export interface IPromptsService extends IDisposable {
* Find all instruction files which have a glob pattern in their
* 'applyTo' metadata record that match the provided list of files.
*/
findInstructionFilesFor(
fileUris: readonly URI[],
): Promise<readonly URI[]>;
findInstructionFilesFor(fileUris: readonly URI[]): Promise<readonly URI[]>;
/**
* Event that is triggered when the list of custom chat modes changes.
@@ -197,7 +194,7 @@ export interface IPromptsService extends IDisposable {
/**
* Finds all available custom chat modes
*/
getCustomChatModes(): Promise<readonly ICustomChatMode[]>;
getCustomChatModes(token: CancellationToken): Promise<readonly ICustomChatMode[]>;
/**
* Gets the metadata for the given prompt file uri.
@@ -212,9 +209,13 @@ export interface IPromptsService extends IDisposable {
* each of the provided files, therefore the result is a number
* of metadata trees, one for each file.
*/
getAllMetadata(
promptUris: readonly URI[],
): Promise<readonly IMetadata[]>;
getAllMetadata(promptUris: readonly URI[]): Promise<readonly IMetadata[]>;
/**
* Parses the provided URI
* @param uris
*/
parse(uri: URI, token: CancellationToken): Promise<IPromptParserResult>;
}
export interface IChatPromptSlashCommand {
@@ -222,3 +223,11 @@ export interface IChatPromptSlashCommand {
readonly detail: string;
readonly promptPath?: IPromptPath;
}
export interface IPromptParserResult {
readonly uri: URI;
readonly metadata: TMetadata | null;
readonly topError: ITopError | undefined;
readonly allValidReferences: readonly URI[];
}
@@ -25,7 +25,7 @@ import { IModelService } from '../../../../../../editor/common/services/model.js
import { CancellationToken } from '../../../../../../base/common/cancellation.js';
import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js';
import { IUserDataProfileService } from '../../../../../services/userDataProfile/common/userDataProfile.js';
import type { IChatPromptSlashCommand, ICustomChatMode, IMetadata, IPromptPath, IPromptsService, TPromptsStorage } from './promptsService.js';
import type { IChatPromptSlashCommand, ICustomChatMode, IMetadata, IPromptParserResult, IPromptPath, IPromptsService, TPromptsStorage } from './promptsService.js';
import { getCleanPromptName, PROMPT_FILE_EXTENSION } from '../config/promptFileLocations.js';
/**
@@ -190,8 +190,8 @@ export class PromptsService extends Disposable implements IPromptsService {
});
}
public async getCustomChatModes(): Promise<readonly ICustomChatMode[]> {
const modeFiles = (await this.listPromptFiles(PromptsType.mode, CancellationToken.None))
public async getCustomChatModes(token: CancellationToken): Promise<readonly ICustomChatMode[]> {
const modeFiles = (await this.listPromptFiles(PromptsType.mode, token))
.map(modeFile => modeFile.uri);
const metadataList = await Promise.all(
@@ -204,7 +204,7 @@ export class PromptsService extends Disposable implements IPromptsService {
PromptParser,
uri,
{ allowNonPromptFiles: true },
).start();
).start(token);
await parser.settled();
@@ -230,6 +230,24 @@ export class PromptsService extends Disposable implements IPromptsService {
return metadataList;
}
public async parse(uri: URI, token: CancellationToken): Promise<IPromptParserResult> {
let parser: PromptParser | undefined;
try {
parser = this.instantiationService.createInstance(PromptParser, uri, { allowNonPromptFiles: true }).start(token);
await parser.settled();
// make a copy, to avoid leaking the parser instance
return {
uri: parser.uri,
metadata: parser.metadata,
topError: parser.topError,
allValidReferences: parser.allValidReferences.map(ref => ref.uri)
};
} finally {
parser?.dispose();
}
}
public async findInstructionFilesFor(files: readonly URI[]): Promise<readonly URI[]> {
const instructionFiles = await this.listPromptFiles(PromptsType.instructions, CancellationToken.None);
if (instructionFiles.length === 0) {
@@ -8,7 +8,8 @@ import { URI } from '../../../../../base/common/uri.js';
import { ITextModel } from '../../../../../editor/common/model.js';
import { PromptsType } from '../../common/promptSyntax/promptTypes.js';
import { TextModelPromptParser } from '../../common/promptSyntax/parsers/textModelPromptParser.js';
import { IChatPromptSlashCommand, ICustomChatMode, IMetadata, IPromptPath, IPromptsService } from '../../common/promptSyntax/service/promptsService.js';
import { IChatPromptSlashCommand, ICustomChatMode, IMetadata, IPromptParserResult, IPromptPath, IPromptsService } from '../../common/promptSyntax/service/promptsService.js';
import { CancellationToken } from '../../../../../base/common/cancellation.js';
export class MockPromptsService implements IPromptsService {
@@ -42,7 +43,10 @@ export class MockPromptsService implements IPromptsService {
throw new Error('Method not implemented.');
}
onDidChangeCustomChatModes: Event<void> = Event.None;
getCustomChatModes(): Promise<readonly ICustomChatMode[]> {
getCustomChatModes(token: CancellationToken): Promise<readonly ICustomChatMode[]> {
throw new Error('Method not implemented.');
}
parse(uri: URI, token: CancellationToken): Promise<IPromptParserResult> {
throw new Error('Method not implemented.');
}
dispose(): void { }