Merge pull request #266263 from microsoft/aeschli/newPromptParser

new prompt file parser
This commit is contained in:
Martin Aeschlimann
2025-09-15 06:52:05 -07:00
committed by GitHub
22 changed files with 3533 additions and 505 deletions

832
src/vs/base/common/yaml.ts Normal file
View File

@@ -0,0 +1,832 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
/**
* Parses a simplified YAML-like input from a single string.
* Supports objects, arrays, primitive types (string, number, boolean, null).
* Tracks positions for error reporting and node locations.
*
* Limitations:
* - No multi-line strings or block literals
* - No anchors or references
* - No complex types (dates, binary)
* - No special handling for escape sequences in strings
* - Indentation must be consistent (spaces only, no tabs)
*
* Notes:
* - New line separators can be either "\n" or "\r\n". The input string is split into lines internally.
*
* @param input A string containing the YAML-like input
* @param errors Array to collect parsing errors
* @param options Parsing options
* @returns The parsed representation (ObjectNode, ArrayNode, or primitive node)
*/
export function parse(input: string, errors: YamlParseError[] = [], options: ParseOptions = {}): YamlNode | undefined {
// Normalize both LF and CRLF by splitting on either; CR characters are not retained as part of line text.
// This keeps the existing line/character based lexer logic intact.
const lines = input.length === 0 ? [] : input.split(/\r\n|\n/);
const parser = new YamlParser(lines, errors, options);
return parser.parse();
}
export interface YamlParseError {
readonly message: string;
readonly start: Position;
readonly end: Position;
readonly code: string;
}
export interface ParseOptions {
readonly allowDuplicateKeys?: boolean;
}
export interface Position {
readonly line: number;
readonly character: number;
}
export interface YamlStringNode {
readonly type: 'string';
readonly value: string;
readonly start: Position;
readonly end: Position;
}
export interface YamlNumberNode {
readonly type: 'number';
readonly value: number;
readonly start: Position;
readonly end: Position;
}
export interface YamlBooleanNode {
readonly type: 'boolean';
readonly value: boolean;
readonly start: Position;
readonly end: Position;
}
export interface YamlNullNode {
readonly type: 'null';
readonly value: null;
readonly start: Position;
readonly end: Position;
}
export interface YamlObjectNode {
readonly type: 'object';
readonly properties: { key: YamlStringNode; value: YamlNode }[];
readonly start: Position;
readonly end: Position;
}
export interface YamlArrayNode {
readonly type: 'array';
readonly items: YamlNode[];
readonly start: Position;
readonly end: Position;
}
export type YamlNode = YamlStringNode | YamlNumberNode | YamlBooleanNode | YamlNullNode | YamlObjectNode | YamlArrayNode;
// Helper functions for position and node creation
function createPosition(line: number, character: number): Position {
return { line, character };
}
// Specialized node creation functions using a more concise approach
function createStringNode(value: string, start: Position, end: Position): YamlStringNode {
return { type: 'string', value, start, end };
}
function createNumberNode(value: number, start: Position, end: Position): YamlNumberNode {
return { type: 'number', value, start, end };
}
function createBooleanNode(value: boolean, start: Position, end: Position): YamlBooleanNode {
return { type: 'boolean', value, start, end };
}
function createNullNode(start: Position, end: Position): YamlNullNode {
return { type: 'null', value: null, start, end };
}
function createObjectNode(properties: { key: YamlStringNode; value: YamlNode }[], start: Position, end: Position): YamlObjectNode {
return { type: 'object', start, end, properties };
}
function createArrayNode(items: YamlNode[], start: Position, end: Position): YamlArrayNode {
return { type: 'array', start, end, items };
}
// Utility functions for parsing
function isWhitespace(char: string): boolean {
return char === ' ' || char === '\t';
}
// Simplified number validation using regex
function isValidNumber(value: string): boolean {
return /^-?\d*\.?\d+$/.test(value);
}
// Lexer/Tokenizer for YAML content
class YamlLexer {
private lines: string[];
private currentLine: number = 0;
private currentChar: number = 0;
constructor(lines: string[]) {
this.lines = lines;
}
getCurrentPosition(): Position {
return createPosition(this.currentLine, this.currentChar);
}
getCurrentLineNumber(): number {
return this.currentLine;
}
getCurrentCharNumber(): number {
return this.currentChar;
}
getCurrentLineText(): string {
return this.currentLine < this.lines.length ? this.lines[this.currentLine] : '';
}
savePosition(): { line: number; char: number } {
return { line: this.currentLine, char: this.currentChar };
}
restorePosition(pos: { line: number; char: number }): void {
this.currentLine = pos.line;
this.currentChar = pos.char;
}
isAtEnd(): boolean {
return this.currentLine >= this.lines.length;
}
getCurrentChar(): string {
if (this.isAtEnd() || this.currentChar >= this.lines[this.currentLine].length) {
return '';
}
return this.lines[this.currentLine][this.currentChar];
}
peek(offset: number = 1): string {
const newChar = this.currentChar + offset;
if (this.currentLine >= this.lines.length || newChar >= this.lines[this.currentLine].length) {
return '';
}
return this.lines[this.currentLine][newChar];
}
advance(): string {
const char = this.getCurrentChar();
if (this.currentChar >= this.lines[this.currentLine].length && this.currentLine < this.lines.length - 1) {
this.currentLine++;
this.currentChar = 0;
} else {
this.currentChar++;
}
return char;
}
advanceLine(): void {
this.currentLine++;
this.currentChar = 0;
}
skipWhitespace(): void {
while (!this.isAtEnd() && this.currentChar < this.lines[this.currentLine].length && isWhitespace(this.getCurrentChar())) {
this.advance();
}
}
skipToEndOfLine(): void {
this.currentChar = this.lines[this.currentLine].length;
}
getIndentation(): number {
if (this.isAtEnd()) {
return 0;
}
let indent = 0;
for (let i = 0; i < this.lines[this.currentLine].length; i++) {
if (this.lines[this.currentLine][i] === ' ') {
indent++;
} else if (this.lines[this.currentLine][i] === '\t') {
indent += 4; // Treat tab as 4 spaces
} else {
break;
}
}
return indent;
}
moveToNextNonEmptyLine(): void {
while (this.currentLine < this.lines.length) {
// First check current line from current position
if (this.currentChar < this.lines[this.currentLine].length) {
const remainingLine = this.lines[this.currentLine].substring(this.currentChar).trim();
if (remainingLine.length > 0 && !remainingLine.startsWith('#')) {
this.skipWhitespace();
return;
}
}
// Move to next line and check from beginning
this.currentLine++;
this.currentChar = 0;
if (this.currentLine < this.lines.length) {
const line = this.lines[this.currentLine].trim();
if (line.length > 0 && !line.startsWith('#')) {
this.skipWhitespace();
return;
}
}
}
}
}
// Parser class for handling YAML parsing
class YamlParser {
private lexer: YamlLexer;
private errors: YamlParseError[];
private options: ParseOptions;
// Track nesting level of flow (inline) collections '[' ']' '{' '}'
private flowLevel: number = 0;
constructor(lines: string[], errors: YamlParseError[], options: ParseOptions) {
this.lexer = new YamlLexer(lines);
this.errors = errors;
this.options = options;
}
addError(message: string, code: string, start: Position, end: Position): void {
this.errors.push({ message, code, start, end });
}
parseValue(expectedIndent?: number): YamlNode {
this.lexer.skipWhitespace();
if (this.lexer.isAtEnd()) {
const pos = this.lexer.getCurrentPosition();
return createStringNode('', pos, pos);
}
const char = this.lexer.getCurrentChar();
// Handle quoted strings
if (char === '"' || char === `'`) {
return this.parseQuotedString(char);
}
// Handle inline arrays
if (char === '[') {
return this.parseInlineArray();
}
// Handle inline objects
if (char === '{') {
return this.parseInlineObject();
}
// Handle unquoted values
return this.parseUnquotedValue();
}
parseQuotedString(quote: string): YamlNode {
const start = this.lexer.getCurrentPosition();
this.lexer.advance(); // Skip opening quote
let value = '';
while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '' && this.lexer.getCurrentChar() !== quote) {
value += this.lexer.advance();
}
if (this.lexer.getCurrentChar() === quote) {
this.lexer.advance(); // Skip closing quote
}
const end = this.lexer.getCurrentPosition();
return createStringNode(value, start, end);
}
parseUnquotedValue(): YamlNode {
const start = this.lexer.getCurrentPosition();
let value = '';
let endPos = start;
// Helper function to check for value terminators
const isTerminator = (char: string): boolean => {
if (char === '#') { return true; }
// Comma, ']' and '}' only terminate inside flow collections
if (this.flowLevel > 0 && (char === ',' || char === ']' || char === '}')) { return true; }
return false;
};
// Handle opening quote that might not be closed
const firstChar = this.lexer.getCurrentChar();
if (firstChar === '"' || firstChar === `'`) {
value += this.lexer.advance();
endPos = this.lexer.getCurrentPosition();
while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '') {
const char = this.lexer.getCurrentChar();
if (char === firstChar || isTerminator(char)) {
break;
}
value += this.lexer.advance();
endPos = this.lexer.getCurrentPosition();
}
} else {
while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '') {
const char = this.lexer.getCurrentChar();
if (isTerminator(char)) {
break;
}
value += this.lexer.advance();
endPos = this.lexer.getCurrentPosition();
}
}
const trimmed = value.trimEnd();
const diff = value.length - trimmed.length;
if (diff) {
endPos = createPosition(start.line, endPos.character - diff);
}
const finalValue = (firstChar === '"' || firstChar === `'`) ? trimmed.substring(1) : trimmed;
return this.createValueNode(finalValue, start, endPos);
}
private createValueNode(value: string, start: Position, end: Position): YamlNode {
if (value === '') {
return createStringNode('', start, start);
}
// Boolean values
if (value === 'true') {
return createBooleanNode(true, start, end);
}
if (value === 'false') {
return createBooleanNode(false, start, end);
}
// Null values
if (value === 'null' || value === '~') {
return createNullNode(start, end);
}
// Number values
const numberValue = Number(value);
if (!isNaN(numberValue) && isFinite(numberValue) && isValidNumber(value)) {
return createNumberNode(numberValue, start, end);
}
// Default to string
return createStringNode(value, start, end);
}
parseInlineArray(): YamlArrayNode {
const start = this.lexer.getCurrentPosition();
this.lexer.advance(); // Skip '['
this.flowLevel++;
const items: YamlNode[] = [];
while (!this.lexer.isAtEnd()) {
this.lexer.skipWhitespace();
// Handle end of array
if (this.lexer.getCurrentChar() === ']') {
this.lexer.advance();
break;
}
// Handle end of line - continue to next line for multi-line arrays
if (this.lexer.getCurrentChar() === '') {
this.lexer.advanceLine();
continue;
}
// Parse array item
const item = this.parseValue();
items.push(item);
this.lexer.skipWhitespace();
// Handle comma separator
if (this.lexer.getCurrentChar() === ',') {
this.lexer.advance();
}
}
const end = this.lexer.getCurrentPosition();
this.flowLevel--;
return createArrayNode(items, start, end);
}
parseInlineObject(): YamlObjectNode {
const start = this.lexer.getCurrentPosition();
this.lexer.advance(); // Skip '{'
this.flowLevel++;
const properties: { key: YamlStringNode; value: YamlNode }[] = [];
while (!this.lexer.isAtEnd()) {
this.lexer.skipWhitespace();
// Handle end of object
if (this.lexer.getCurrentChar() === '}') {
this.lexer.advance();
break;
}
// Parse key - read until colon
const keyStart = this.lexer.getCurrentPosition();
let keyValue = '';
// Handle quoted keys
if (this.lexer.getCurrentChar() === '"' || this.lexer.getCurrentChar() === `'`) {
const quote = this.lexer.getCurrentChar();
this.lexer.advance(); // Skip opening quote
while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '' && this.lexer.getCurrentChar() !== quote) {
keyValue += this.lexer.advance();
}
if (this.lexer.getCurrentChar() === quote) {
this.lexer.advance(); // Skip closing quote
}
} else {
// Handle unquoted keys - read until colon
while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '' && this.lexer.getCurrentChar() !== ':') {
keyValue += this.lexer.advance();
}
}
keyValue = keyValue.trim();
const keyEnd = this.lexer.getCurrentPosition();
const key = createStringNode(keyValue, keyStart, keyEnd);
this.lexer.skipWhitespace();
// Expect colon
if (this.lexer.getCurrentChar() === ':') {
this.lexer.advance();
}
this.lexer.skipWhitespace();
// Parse value
const value = this.parseValue();
properties.push({ key, value });
this.lexer.skipWhitespace();
// Handle comma separator
if (this.lexer.getCurrentChar() === ',') {
this.lexer.advance();
}
}
const end = this.lexer.getCurrentPosition();
this.flowLevel--;
return createObjectNode(properties, start, end);
}
parseBlockArray(baseIndent: number): YamlArrayNode {
const start = this.lexer.getCurrentPosition();
const items: YamlNode[] = [];
while (!this.lexer.isAtEnd()) {
this.lexer.moveToNextNonEmptyLine();
if (this.lexer.isAtEnd()) {
break;
}
const currentIndent = this.lexer.getIndentation();
// If indentation is less than expected, we're done with this array
if (currentIndent < baseIndent) {
break;
}
this.lexer.skipWhitespace();
// Check for array item marker
if (this.lexer.getCurrentChar() === '-') {
this.lexer.advance(); // Skip '-'
this.lexer.skipWhitespace();
const itemStart = this.lexer.getCurrentPosition();
// Check if this is a nested structure
if (this.lexer.getCurrentChar() === '' || this.lexer.getCurrentChar() === '#') {
// Empty item - check if next lines form a nested structure
this.lexer.advanceLine();
if (!this.lexer.isAtEnd()) {
const nextIndent = this.lexer.getIndentation();
if (nextIndent > currentIndent) {
// Check if the next line starts with a dash (nested array) or has properties (nested object)
this.lexer.skipWhitespace();
if (this.lexer.getCurrentChar() === '-') {
// It's a nested array
const nestedArray = this.parseBlockArray(nextIndent);
items.push(nestedArray);
} else {
// Check if it looks like an object property (has a colon)
const currentLine = this.lexer.getCurrentLineText();
const currentPos = this.lexer.getCurrentCharNumber();
const remainingLine = currentLine.substring(currentPos);
if (remainingLine.includes(':') && !remainingLine.trim().startsWith('#')) {
// It's a nested object
const nestedObject = this.parseBlockObject(nextIndent, this.lexer.getCurrentCharNumber());
items.push(nestedObject);
} else {
// Not a nested structure, create empty string
items.push(createStringNode('', itemStart, itemStart));
}
}
} else {
// No nested content, empty item
items.push(createStringNode('', itemStart, itemStart));
}
} else {
// End of input, empty item
items.push(createStringNode('', itemStart, itemStart));
}
} else {
// Parse the item value
// Check if this is a multi-line object by looking for a colon and checking next lines
const currentLine = this.lexer.getCurrentLineText();
const currentPos = this.lexer.getCurrentCharNumber();
const remainingLine = currentLine.substring(currentPos);
// Check if there's a colon on this line (indicating object properties)
const hasColon = remainingLine.includes(':');
if (hasColon) {
// Any line with a colon should be treated as an object
// Parse as an object with the current item's indentation as the base
const item = this.parseBlockObject(itemStart.character, itemStart.character);
items.push(item);
} else {
// No colon, parse as regular value
const item = this.parseValue();
items.push(item);
// Skip to end of line
while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '' && this.lexer.getCurrentChar() !== '#') {
this.lexer.advance();
}
this.lexer.advanceLine();
}
}
} else {
// No dash found at expected indent level, break
break;
}
}
// Calculate end position based on the last item
let end = start;
if (items.length > 0) {
const lastItem = items[items.length - 1];
end = lastItem.end;
} else {
// If no items, end is right after the start
end = createPosition(start.line, start.character + 1);
}
return createArrayNode(items, start, end);
}
parseBlockObject(baseIndent: number, baseCharPosition?: number): YamlObjectNode {
const start = this.lexer.getCurrentPosition();
const properties: { key: YamlStringNode; value: YamlNode }[] = [];
const localKeysSeen = new Set<string>();
// For parsing from current position (inline object parsing)
const fromCurrentPosition = baseCharPosition !== undefined;
let firstIteration = true;
while (!this.lexer.isAtEnd()) {
if (!firstIteration || !fromCurrentPosition) {
this.lexer.moveToNextNonEmptyLine();
}
firstIteration = false;
if (this.lexer.isAtEnd()) {
break;
}
const currentIndent = this.lexer.getIndentation();
if (fromCurrentPosition) {
// For current position parsing, check character position alignment
this.lexer.skipWhitespace();
const currentCharPosition = this.lexer.getCurrentCharNumber();
if (currentCharPosition < baseCharPosition) {
break;
}
} else {
// For normal block parsing, check indentation level
if (currentIndent < baseIndent) {
break;
}
// Check for incorrect indentation
if (currentIndent > baseIndent) {
const lineStart = createPosition(this.lexer.getCurrentLineNumber(), 0);
const lineEnd = createPosition(this.lexer.getCurrentLineNumber(), this.lexer.getCurrentLineText().length);
this.addError('Unexpected indentation', 'indentation', lineStart, lineEnd);
// Try to recover by treating it as a property anyway
this.lexer.skipWhitespace();
} else {
this.lexer.skipWhitespace();
}
}
// Parse key
const keyStart = this.lexer.getCurrentPosition();
let keyValue = '';
while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '' && this.lexer.getCurrentChar() !== ':') {
keyValue += this.lexer.advance();
}
keyValue = keyValue.trim();
const keyEnd = this.lexer.getCurrentPosition();
const key = createStringNode(keyValue, keyStart, keyEnd);
// Check for duplicate keys
if (!this.options.allowDuplicateKeys && localKeysSeen.has(keyValue)) {
this.addError(`Duplicate key '${keyValue}'`, 'duplicateKey', keyStart, keyEnd);
}
localKeysSeen.add(keyValue);
// Expect colon
if (this.lexer.getCurrentChar() === ':') {
this.lexer.advance();
}
this.lexer.skipWhitespace();
// Determine if value is on same line or next line(s)
let value: YamlNode;
const valueStart = this.lexer.getCurrentPosition();
if (this.lexer.getCurrentChar() === '' || this.lexer.getCurrentChar() === '#') {
// Value is on next line(s) or empty
this.lexer.advanceLine();
// Check next line for nested content
if (!this.lexer.isAtEnd()) {
const nextIndent = this.lexer.getIndentation();
if (nextIndent > currentIndent) {
// Nested content - determine if it's an object or array
this.lexer.skipWhitespace();
if (this.lexer.getCurrentChar() === '-') {
value = this.parseBlockArray(nextIndent);
} else {
value = this.parseBlockObject(nextIndent);
}
} else if (!fromCurrentPosition && nextIndent === currentIndent) {
// Same indentation level - check if it's an array item
this.lexer.skipWhitespace();
if (this.lexer.getCurrentChar() === '-') {
value = this.parseBlockArray(currentIndent);
} else {
value = createStringNode('', valueStart, valueStart);
}
} else {
value = createStringNode('', valueStart, valueStart);
}
} else {
value = createStringNode('', valueStart, valueStart);
}
} else {
// Value is on the same line
value = this.parseValue();
// Skip any remaining content on this line (comments, etc.)
while (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() !== '' && this.lexer.getCurrentChar() !== '#') {
if (isWhitespace(this.lexer.getCurrentChar())) {
this.lexer.advance();
} else {
break;
}
}
// Skip to end of line if we hit a comment
if (this.lexer.getCurrentChar() === '#') {
this.lexer.skipToEndOfLine();
}
// Move to next line for next iteration
if (!this.lexer.isAtEnd() && this.lexer.getCurrentChar() === '') {
this.lexer.advanceLine();
}
}
properties.push({ key, value });
}
// Calculate the end position based on the last property
let end = start;
if (properties.length > 0) {
const lastProperty = properties[properties.length - 1];
end = lastProperty.value.end;
}
return createObjectNode(properties, start, end);
}
parse(): YamlNode | undefined {
if (this.lexer.isAtEnd()) {
return undefined;
}
this.lexer.moveToNextNonEmptyLine();
if (this.lexer.isAtEnd()) {
return undefined;
}
// Determine the root structure type
this.lexer.skipWhitespace();
if (this.lexer.getCurrentChar() === '-') {
// Check if this is an array item or a negative number
// Look at the character after the dash
const nextChar = this.lexer.peek();
if (nextChar === ' ' || nextChar === '\t' || nextChar === '' || nextChar === '#') {
// It's an array item (dash followed by whitespace/end/comment)
return this.parseBlockArray(0);
} else {
// It's likely a negative number or other value, treat as single value
return this.parseValue();
}
} else if (this.lexer.getCurrentChar() === '[') {
// Root is an inline array
return this.parseInlineArray();
} else if (this.lexer.getCurrentChar() === '{') {
// Root is an inline object
return this.parseInlineObject();
} else {
// Check if this looks like a key-value pair by looking for a colon
// For single values, there shouldn't be a colon
const currentLine = this.lexer.getCurrentLineText();
const currentPos = this.lexer.getCurrentCharNumber();
const remainingLine = currentLine.substring(currentPos);
// Check if there's a colon that's not inside quotes
let hasColon = false;
let inQuotes = false;
let quoteChar = '';
for (let i = 0; i < remainingLine.length; i++) {
const char = remainingLine[i];
if (!inQuotes && (char === '"' || char === `'`)) {
inQuotes = true;
quoteChar = char;
} else if (inQuotes && char === quoteChar) {
inQuotes = false;
quoteChar = '';
} else if (!inQuotes && char === ':') {
hasColon = true;
break;
} else if (!inQuotes && char === '#') {
// Comment starts, stop looking
break;
}
}
if (hasColon) {
// Root is an object
return this.parseBlockObject(0);
} else {
// Root is a single value
return this.parseValue();
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -64,9 +64,8 @@ import { ChatAgentLocation, ChatConfiguration, ChatModeKind, TodoListWidgetPosit
import { ILanguageModelToolsService, IToolData, ToolSet } from '../common/languageModelToolsService.js';
import { ComputeAutomaticInstructions } from '../common/promptSyntax/computeAutomaticInstructions.js';
import { PromptsConfig } from '../common/promptSyntax/config/config.js';
import { type TPromptMetadata } from '../common/promptSyntax/parsers/promptHeader/promptHeader.js';
import { PromptsType } from '../common/promptSyntax/promptTypes.js';
import { IPromptParserResult, IPromptsService } from '../common/promptSyntax/service/promptsService.js';
import { IPromptsService } from '../common/promptSyntax/service/promptsService.js';
import { handleModeSwitch } from './actions/chatActions.js';
import { ChatTreeItem, IChatAcceptInputOptions, IChatAccessibilityService, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions, IChatWidget, IChatWidgetService, IChatWidgetViewContext, IChatWidgetViewOptions, ChatViewId } from './chat.js';
import { ChatAccessibilityProvider } from './chatAccessibilityProvider.js';
@@ -84,6 +83,8 @@ import { IViewsService } from '../../../services/views/common/viewsService.js';
import product from '../../../../platform/product/common/product.js';
import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js';
import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js';
import { ParsedPromptFile, PromptHeader } from '../common/promptSyntax/service/newPromptsParser.js';
import { OffsetRange } from '../../../../editor/common/core/ranges/offsetRange.js';
const $ = dom.$;
@@ -1340,13 +1341,13 @@ export class ChatWidget extends Disposable implements IChatWidget {
if (promptNames.includes(promptCommand.command)) {
try {
if (promptCommand.promptPath) {
const parseResult = await this.promptsService.parse(
const parseResult = await this.promptsService.parseNew(
promptCommand.promptPath.uri,
promptCommand.promptPath.type,
CancellationToken.None
);
if (parseResult.metadata?.description) {
this.promptDescriptionsCache.set(promptCommand.command, parseResult.metadata.description);
const description = parseResult.header?.description;
if (description) {
this.promptDescriptionsCache.set(promptCommand.command, description);
} else {
// Set empty string to indicate we've checked this prompt
this.promptDescriptionsCache.set(promptCommand.command, '');
@@ -2145,21 +2146,22 @@ export class ChatWidget extends Disposable implements IChatWidget {
return undefined;
}
private async _applyPromptFileIfSet(requestInput: IChatRequestInputOptions): Promise<IPromptParserResult | undefined> {
private async _applyPromptFileIfSet(requestInput: IChatRequestInputOptions): Promise<void> {
if (!PromptsConfig.enabled(this.configurationService)) {
// if prompts are not enabled, we don't need to do anything
return undefined;
}
let parseResult: IPromptParserResult | undefined;
let parseResult: ParsedPromptFile | undefined;
// first check if the input has a prompt slash command
const agentSlashPromptPart = this.parsedInput.parts.find((r): r is ChatRequestSlashPromptPart => r instanceof ChatRequestSlashPromptPart);
if (agentSlashPromptPart) {
parseResult = await this.promptsService.resolvePromptSlashCommand(agentSlashPromptPart.slashPromptCommand, CancellationToken.None);
if (parseResult) {
// add the prompt file to the context, but not sticky
const toolReferences = this.toolsService.toToolReferences(parseResult.variableReferences);
// add the prompt file to the context
const refs = parseResult.body?.variableReferences.map(({ name, offset }) => ({ name, range: new OffsetRange(offset, offset + name.length + 1) })) ?? [];
const toolReferences = this.toolsService.toToolReferences(refs);
requestInput.attachedContext.insertFirst(toPromptFileVariableEntry(parseResult.uri, PromptFileVariableKind.PromptFile, undefined, true, toolReferences));
// remove the slash command from the input
@@ -2170,7 +2172,7 @@ export class ChatWidget extends Disposable implements IChatWidget {
const uri = this._findPromptFileInContext(requestInput.attachedContext);
if (uri) {
try {
parseResult = await this.promptsService.parse(uri, PromptsType.prompt, CancellationToken.None);
parseResult = await this.promptsService.parseNew(uri, CancellationToken.None);
} catch (error) {
this.logService.error(`[_applyPromptFileIfSet] Failed to parse prompt file: ${uri}`, error);
}
@@ -2180,10 +2182,6 @@ export class ChatWidget extends Disposable implements IChatWidget {
if (!parseResult) {
return undefined;
}
const meta = parseResult.metadata;
if (meta?.promptType !== PromptsType.prompt) {
return undefined;
}
const input = requestInput.input.trim();
requestInput.input = `Follow instructions in [${basename(parseResult.uri)}](${parseResult.uri.toString()}).`;
@@ -2191,10 +2189,9 @@ export class ChatWidget extends Disposable implements IChatWidget {
// if the input is not empty, append it to the prompt
requestInput.input += `\n${input}`;
}
await this._applyPromptMetadata(meta, requestInput);
return parseResult;
if (parseResult.header) {
await this._applyPromptMetadata(parseResult.header, requestInput);
}
}
private async _acceptInput(query: { query: string } | undefined, options?: IChatAcceptInputOptions): Promise<IChatResponseModel | undefined> {
@@ -2533,12 +2530,14 @@ export class ChatWidget extends Disposable implements IChatWidget {
this.agentInInput.set(!!currentAgent);
}
private async _applyPromptMetadata(metadata: TPromptMetadata, requestInput: IChatRequestInputOptions): Promise<void> {
const { mode, tools, model } = metadata;
private async _applyPromptMetadata({ mode, tools, model }: PromptHeader, requestInput: IChatRequestInputOptions): Promise<void> {
const currentMode = this.input.currentModeObs.get();
if (tools !== undefined && !mode && currentMode.kind !== ChatModeKind.Agent) {
mode = ChatModeKind.Agent;
}
// switch to appropriate chat mode if needed
if (mode && mode !== currentMode.name) {
// Find the mode object to get its kind

View File

@@ -26,30 +26,20 @@ export class PromptFileRewriter {
}
const model = editor.getModel();
const parser = this._promptsService.getSyntaxParserFor(model);
await parser.start(token).settled();
const { header } = parser;
if (header === undefined) {
const parser = this._promptsService.getParsedPromptFile(model);
if (!parser.header) {
return undefined;
}
const completed = await header.settled;
if (!completed || token.isCancellationRequested) {
return;
const toolsAttr = parser.header.getAttribute('tools');
if (!toolsAttr) {
return undefined;
}
if (('tools' in header.metadataUtility) === false) {
return undefined;
}
const { tools } = header.metadataUtility;
if (tools === undefined) {
return undefined;
}
editor.setSelection(tools.range);
this.rewriteTools(model, newTools, tools.range);
editor.setSelection(toolsAttr.range);
this.rewriteTools(model, newTools, toolsAttr.range);
}
public rewriteTools(model: ITextModel, newTools: IToolAndToolSetEnablementMap | undefined, range: Range): void {
const newString = newTools === undefined ? '' : `tools: ${this.getNewValueString(newTools)}`;
model.pushStackElement();

View File

@@ -15,10 +15,10 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com
import { showToolsPicker } from '../actions/chatToolPicker.js';
import { ILanguageModelToolsService } from '../../common/languageModelToolsService.js';
import { ALL_PROMPTS_LANGUAGE_SELECTOR } from '../../common/promptSyntax/promptTypes.js';
import { PromptToolsMetadata } from '../../common/promptSyntax/parsers/promptHeader/metadata/tools.js';
import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js';
import { registerEditorFeature } from '../../../../../editor/common/editorFeatures.js';
import { PromptFileRewriter } from './promptFileRewriter.js';
import { Range } from '../../../../../editor/common/core/range.js';
class PromptToolsCodeLensProvider extends Disposable implements CodeLensProvider {
@@ -37,56 +37,47 @@ class PromptToolsCodeLensProvider extends Disposable implements CodeLensProvider
this._register(this.languageService.codeLensProvider.register(ALL_PROMPTS_LANGUAGE_SELECTOR, this));
this._register(CommandsRegistry.registerCommand(this.cmdId, (_accessor, ...args) => {
const [first, second] = args;
if (isITextModel(first) && second instanceof PromptToolsMetadata) {
this.updateTools(first, second);
const [first, second, third] = args;
if (isITextModel(first) && Range.isIRange(second) && Array.isArray(third)) {
this.updateTools(first, Range.lift(second), third);
}
}));
}
async provideCodeLenses(model: ITextModel, token: CancellationToken): Promise<undefined | CodeLensList> {
const parser = this.promptsService.getSyntaxParserFor(model);
await parser.start(token).settled();
const { header } = parser;
if (!header) {
const parser = this.promptsService.getParsedPromptFile(model);
if (!parser.header) {
return undefined;
}
const completed = await header.settled;
if (!completed || token.isCancellationRequested) {
return undefined;
}
if (('tools' in header.metadataUtility) === false) {
return undefined;
}
const { tools } = header.metadataUtility;
if (tools === undefined) {
const toolsAttr = parser.header.getAttribute('tools');
if (!toolsAttr || toolsAttr.value.type !== 'array') {
return undefined;
}
const items = toolsAttr.value.items;
const selectedTools = items.filter(item => item.type === 'string').map(item => item.value);
const codeLens: CodeLens = {
range: tools.range.collapseToStart(),
range: toolsAttr.range.collapseToStart(),
command: {
title: localize('configure-tools.capitalized.ellipsis', "Configure Tools..."),
id: this.cmdId,
arguments: [model, tools]
arguments: [model, toolsAttr.range, selectedTools]
}
};
return { lenses: [codeLens] };
}
private async updateTools(model: ITextModel, tools: PromptToolsMetadata) {
private async updateTools(model: ITextModel, range: Range, selectedTools: readonly string[]) {
const selectedToolsNow = () => tools.value ? this.languageModelToolsService.toToolAndToolSetEnablementMap(tools.value) : new Map();
const selectedToolsNow = () => this.languageModelToolsService.toToolAndToolSetEnablementMap(selectedTools);
const newSelectedAfter = await this.instantiationService.invokeFunction(showToolsPicker, localize('placeholder', "Select tools"), undefined, selectedToolsNow);
if (!newSelectedAfter) {
return;
}
await this.instantiationService.createInstance(PromptFileRewriter).rewriteTools(model, newSelectedAfter, tools.range);
await this.instantiationService.createInstance(PromptFileRewriter).rewriteTools(model, newSelectedAfter, range);
}
}

View File

@@ -21,7 +21,8 @@ import { IToolData } from '../languageModelToolsService.js';
import { PromptsConfig } from './config/config.js';
import { COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, isPromptOrInstructionsFile } from './config/promptFileLocations.js';
import { PromptsType } from './promptTypes.js';
import { IPromptParserResult, IPromptPath, IPromptsService } from './service/promptsService.js';
import { ParsedPromptFile } from './service/newPromptsParser.js';
import { IPromptPath, IPromptsService } from './service/promptsService.js';
export type InstructionsCollectionEvent = {
applyingInstructionsCount: number;
@@ -46,7 +47,7 @@ type InstructionsCollectionClassification = {
export class ComputeAutomaticInstructions {
private _parseResults: ResourceMap<IPromptParserResult> = new ResourceMap();
private _parseResults: ResourceMap<ParsedPromptFile> = new ResourceMap();
constructor(
private readonly _readFileTool: IToolData | undefined,
@@ -60,12 +61,12 @@ export class ComputeAutomaticInstructions {
) {
}
private async _parseInstructionsFile(uri: URI, token: CancellationToken): Promise<IPromptParserResult | undefined> {
private async _parseInstructionsFile(uri: URI, token: CancellationToken): Promise<ParsedPromptFile | undefined> {
if (this._parseResults.has(uri)) {
return this._parseResults.get(uri)!;
}
try {
const result = await this._promptsService.parse(uri, PromptsType.instructions, token);
const result = await this._promptsService.parseNew(uri, token);
this._parseResults.set(uri, result);
return result;
} catch (error) {
@@ -125,11 +126,7 @@ export class ComputeAutomaticInstructions {
continue;
}
if (parsedFile.metadata?.promptType !== PromptsType.instructions) {
this._logService.trace(`[InstructionsContextComputer] Not an instruction file: ${uri}`);
continue;
}
const applyTo = parsedFile.metadata.applyTo;
const applyTo = parsedFile.header?.applyTo;
if (!applyTo) {
this._logService.trace(`[InstructionsContextComputer] No 'applyTo' found: ${uri}`);
@@ -262,12 +259,11 @@ export class ComputeAutomaticInstructions {
const entries: string[] = [];
for (const { uri } of instructionFiles) {
const parsedFile = await this._parseInstructionsFile(uri, token);
if (parsedFile?.metadata?.promptType !== PromptsType.instructions) {
continue;
if (parsedFile) {
const applyTo = parsedFile.header?.applyTo ?? '**/*';
const description = parsedFile.header?.description ?? '';
entries.push(`| '${getFilePath(uri)}' | ${applyTo} | ${description} |`);
}
const applyTo = parsedFile.metadata.applyTo ?? '**/*';
const description = parsedFile.metadata.description ?? '';
entries.push(`| '${getFilePath(uri)}' | ${applyTo} | ${description} |`);
}
if (entries.length === 0) {
return entries;
@@ -299,13 +295,14 @@ export class ComputeAutomaticInstructions {
let next = todo.pop();
while (next) {
const result = await this._parseInstructionsFile(next, token);
if (result) {
if (result && result.body) {
const refsToCheck: { resource: URI }[] = [];
for (const ref of result.fileReferences) {
if (!seen.has(ref) && (isPromptOrInstructionsFile(ref) || this._workspaceService.getWorkspaceFolder(ref) !== undefined)) {
for (const ref of result.body.fileReferences) {
const url = result.body.resolveFilePath(ref.content);
if (url && !seen.has(url) && (isPromptOrInstructionsFile(url) || this._workspaceService.getWorkspaceFolder(url) !== undefined)) {
// only add references that are either prompt or instruction files or are part of the workspace
refsToCheck.push({ resource: ref });
seen.add(ref);
refsToCheck.push({ resource: url });
seen.add(url);
}
}
if (refsToCheck.length > 0) {

View File

@@ -13,8 +13,6 @@ import { ILanguageFeaturesService } from '../../../../../../editor/common/servic
import { IChatModeService } from '../../chatModes.js';
import { ALL_PROMPTS_LANGUAGE_SELECTOR, getPromptsTypeForLanguageId } from '../promptTypes.js';
import { IPromptsService } from '../service/promptsService.js';
import { PromptModeMetadata } from '../parsers/promptHeader/metadata/mode.js';
import { PromptHeader } from '../parsers/promptHeader/promptHeader.js';
export class PromptHeaderDefinitionProvider extends Disposable implements DefinitionProvider {
/**
@@ -38,37 +36,15 @@ export class PromptHeaderDefinitionProvider extends Disposable implements Defini
return undefined;
}
const parser = this.promptsService.getSyntaxParserFor(model);
await parser.start(token).settled();
if (token.isCancellationRequested) {
return undefined;
}
const parser = this.promptsService.getParsedPromptFile(model);
const header = parser.header;
if (!header) {
return undefined;
}
const completed = await header.settled;
if (!completed || token.isCancellationRequested) {
return undefined;
}
if (header instanceof PromptHeader) {
const mode = header.metadataUtility.mode;
if (mode?.range.containsPosition(position)) {
return this.getModeDefinition(mode, position);
}
}
return undefined;
}
private getModeDefinition(mode: PromptModeMetadata, position: Position): Definition | undefined {
const value = mode.value;
if (value && mode.valueRange?.containsPosition(position)) {
const mode = this.chatModeService.findModeByName(value);
const modeAttr = header.getAttribute('mode');
if (modeAttr && modeAttr.value.type === 'string' && modeAttr.range.containsPosition(position)) {
const mode = this.chatModeService.findModeByName(modeAttr.value.value);
if (mode && mode.uri) {
return {
uri: mode.uri.get(),

View File

@@ -14,13 +14,10 @@ import { ILanguageFeaturesService } from '../../../../../../editor/common/servic
import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js';
import { ILanguageModelToolsService } from '../../languageModelToolsService.js';
import { IChatModeService } from '../../chatModes.js';
import { InstructionsHeader } from '../parsers/promptHeader/instructionsHeader.js';
import { PromptToolsMetadata } from '../parsers/promptHeader/metadata/tools.js';
import { ModeHeader } from '../parsers/promptHeader/modeHeader.js';
import { PromptHeader } from '../parsers/promptHeader/promptHeader.js';
import { ALL_PROMPTS_LANGUAGE_SELECTOR, getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js';
import { IPromptsService } from '../service/promptsService.js';
import { Iterable } from '../../../../../../base/common/iterator.js';
import { PromptHeader } from '../service/newPromptsParser.js';
export class PromptHeaderAutocompletion extends Disposable implements CompletionItemProvider {
/**
@@ -62,27 +59,14 @@ export class PromptHeaderAutocompletion extends Disposable implements Completion
return undefined;
}
const parser = this.promptsService.getSyntaxParserFor(model);
await parser.start(token).settled();
if (token.isCancellationRequested) {
return undefined;
}
const parser = this.promptsService.getParsedPromptFile(model);
const header = parser.header;
if (!header) {
return undefined;
}
const completed = await header.settled;
if (!completed || token.isCancellationRequested) {
return undefined;
}
const fullHeaderRange = parser.header.range;
const headerRange = new Range(fullHeaderRange.startLineNumber + 1, 0, fullHeaderRange.endLineNumber - 1, model.getLineMaxColumn(fullHeaderRange.endLineNumber - 1),);
if (!headerRange.containsPosition(position)) {
const headerRange = parser.header.range;
if (position.lineNumber < headerRange.startLineNumber || position.lineNumber >= headerRange.endLineNumber) {
// if the position is not inside the header, we don't provide any completions
return undefined;
}
@@ -103,7 +87,7 @@ export class PromptHeaderAutocompletion extends Disposable implements Completion
position: Position,
headerRange: Range,
colonPosition: Position | undefined,
promptType: string,
promptType: PromptsType,
): Promise<CompletionList | undefined> {
const suggestions: CompletionItem[] = [];
@@ -140,9 +124,9 @@ export class PromptHeaderAutocompletion extends Disposable implements Completion
private async provideValueCompletions(
model: ITextModel,
position: Position,
header: PromptHeader | ModeHeader | InstructionsHeader,
header: PromptHeader,
colonPosition: Position,
promptType: string,
promptType: PromptsType,
): Promise<CompletionList | undefined> {
const suggestions: CompletionItem[] = [];
@@ -153,14 +137,11 @@ export class PromptHeaderAutocompletion extends Disposable implements Completion
return undefined;
}
if (header instanceof PromptHeader || header instanceof ModeHeader) {
const tools = header.metadataUtility.tools;
if (tools) {
// if the position is inside the tools metadata, we provide tool name completions
const result = this.provideToolCompletions(model, position, tools);
if (result) {
return result;
}
if (promptType === PromptsType.prompt || promptType === PromptsType.mode) {
// if the position is inside the tools metadata, we provide tool name completions
const result = this.provideToolCompletions(model, position, header);
if (result) {
return result;
}
}
@@ -244,9 +225,9 @@ export class PromptHeaderAutocompletion extends Disposable implements Completion
return result;
}
private provideToolCompletions(model: ITextModel, position: Position, node: PromptToolsMetadata): CompletionList | undefined {
const tools = node.value;
if (!tools || !node.range.containsPosition(position)) {
private provideToolCompletions(model: ITextModel, position: Position, header: PromptHeader): CompletionList | undefined {
const toolsAttr = header.getAttribute('tools');
if (!toolsAttr || toolsAttr.value.type !== 'array' || !toolsAttr.range.containsPosition(position)) {
return undefined;
}
const getSuggestions = (toolRange: Range) => {
@@ -278,11 +259,10 @@ export class PromptHeaderAutocompletion extends Disposable implements Completion
return { suggestions };
};
for (const tool of tools) {
const toolRange = node.getToolRange(tool);
if (toolRange?.containsPosition(position)) {
for (const toolNameNode of toolsAttr.value.items) {
if (toolNameNode.range.containsPosition(position)) {
// if the position is inside a tool range, we provide tool name completions
return getSuggestions(toolRange);
return getSuggestions(toolNameNode.range);
}
}
const prefix = model.getValueInRange(new Range(position.lineNumber, 1, position.lineNumber, position.column));

View File

@@ -15,13 +15,9 @@ import { localize } from '../../../../../../nls.js';
import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js';
import { ILanguageModelToolsService, ToolSet } from '../../languageModelToolsService.js';
import { IChatModeService, isBuiltinChatMode } from '../../chatModes.js';
import { InstructionsHeader } from '../parsers/promptHeader/instructionsHeader.js';
import { PromptModelMetadata } from '../parsers/promptHeader/metadata/model.js';
import { PromptToolsMetadata } from '../parsers/promptHeader/metadata/tools.js';
import { ModeHeader } from '../parsers/promptHeader/modeHeader.js';
import { ALL_PROMPTS_LANGUAGE_SELECTOR, getPromptsTypeForLanguageId } from '../promptTypes.js';
import { ALL_PROMPTS_LANGUAGE_SELECTOR, getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js';
import { IPromptsService } from '../service/promptsService.js';
import { PromptModeMetadata } from '../parsers/promptHeader/metadata/mode.js';
import { IHeaderAttribute } from '../service/newPromptsParser.js';
export class PromptHeaderHoverProvider extends Disposable implements HoverProvider {
/**
@@ -61,60 +57,49 @@ export class PromptHeaderHoverProvider extends Disposable implements HoverProvid
return undefined;
}
const parser = this.promptsService.getSyntaxParserFor(model);
await parser.start(token).settled();
if (token.isCancellationRequested) {
return undefined;
}
const parser = this.promptsService.getParsedPromptFile(model);
const header = parser.header;
if (!header) {
return undefined;
}
const completed = await header.settled;
if (!completed || token.isCancellationRequested) {
return undefined;
}
if (header instanceof InstructionsHeader) {
const descriptionRange = header.metadataUtility.description?.range;
if (promptType === PromptsType.instructions) {
const descriptionRange = header.getAttribute('description')?.range;
if (descriptionRange?.containsPosition(position)) {
return this.createHover(localize('promptHeader.instructions.description', 'The description of the instruction file. It can be used to provide additional context or information about the instructions and is passed to the language model as part of the prompt.'), descriptionRange);
}
const applyToRange = header.metadataUtility.applyTo?.range;
const applyToRange = header.getAttribute('applyTo')?.range;
if (applyToRange?.containsPosition(position)) {
return this.createHover(localize('promptHeader.instructions.applyToRange', 'One or more glob pattern (separated by comma) that describe for which files the instructions apply to. Based on these patterns, the file is automatically included in the prompt, when the context contains a file that matches one or more of these patterns. Use `**` when you want this file to always be added.\nExample: **/*.ts, **/*.js, client/**'), applyToRange);
}
} else if (header instanceof ModeHeader) {
const descriptionRange = header.metadataUtility.description?.range;
} else if (promptType === PromptsType.mode) {
const descriptionRange = header.getAttribute('description')?.range;
if (descriptionRange?.containsPosition(position)) {
return this.createHover(localize('promptHeader.mode.description', 'The description of the mode file. It can be used to provide additional context or information about the mode to the mode author.'), descriptionRange);
}
const model = header.metadataUtility.model;
const model = header.getAttribute('model');
if (model?.range.containsPosition(position)) {
return this.getModelHover(model, model.range, localize('promptHeader.mode.model', 'The model to use in this mode.'));
}
const tools = header.metadataUtility.tools;
const tools = header.getAttribute('tools');
if (tools?.range.containsPosition(position)) {
return this.getToolHover(tools, position, localize('promptHeader.mode.tools', 'The tools to use in this mode.'));
}
} else {
const descriptionRange = header.metadataUtility.description?.range;
const descriptionRange = header.getAttribute('description')?.range;
if (descriptionRange?.containsPosition(position)) {
return this.createHover(localize('promptHeader.prompt.description', 'The description of the prompt file. It can be used to provide additional context or information about the prompt to the prompt author.'), descriptionRange);
}
const model = header.metadataUtility.model;
const model = header.getAttribute('model');
if (model?.range.containsPosition(position)) {
return this.getModelHover(model, model.range, localize('promptHeader.prompt.model', 'The model to use in this prompt.'));
}
const tools = header.metadataUtility.tools;
const tools = header.getAttribute('tools');
if (tools?.range.containsPosition(position)) {
return this.getToolHover(tools, position, localize('promptHeader.prompt.tools', 'The tools to use in this prompt.'));
}
const mode = header.metadataUtility.mode;
const mode = header.getAttribute('mode');
if (mode?.range.containsPosition(position)) {
return this.getModeHover(mode, position, localize('promptHeader.prompt.mode', 'The mode to use in this prompt.'));
}
@@ -122,19 +107,17 @@ export class PromptHeaderHoverProvider extends Disposable implements HoverProvid
return undefined;
}
private getToolHover(node: PromptToolsMetadata, position: Position, baseMessage: string): Hover | undefined {
if (node.value) {
for (const toolName of node.value) {
const toolRange = node.getToolRange(toolName);
if (toolRange?.containsPosition(position)) {
const tool = this.languageModelToolsService.getToolByName(toolName);
private getToolHover(node: IHeaderAttribute, position: Position, baseMessage: string): Hover | undefined {
if (node.value.type === 'array') {
for (const toolName of node.value.items) {
if (toolName.type === 'string' && toolName.range.containsPosition(position)) {
const tool = this.languageModelToolsService.getToolByName(toolName.value);
if (tool) {
return this.createHover(tool.modelDescription, toolRange);
return this.createHover(tool.modelDescription, toolName.range);
}
const toolSet = this.languageModelToolsService.getToolSetByName(toolName);
const toolSet = this.languageModelToolsService.getToolSetByName(toolName.value);
if (toolSet) {
return this.getToolsetHover(toolSet, toolRange);
return this.getToolsetHover(toolSet, toolName.range);
}
}
}
@@ -154,12 +137,11 @@ export class PromptHeaderHoverProvider extends Disposable implements HoverProvid
return this.createHover(lines.join('\n'), range);
}
private getModelHover(node: PromptModelMetadata, range: Range, baseMessage: string): Hover | undefined {
const modelName = node.value;
if (modelName) {
private getModelHover(node: IHeaderAttribute, range: Range, baseMessage: string): Hover | undefined {
if (node.value.type === 'string') {
for (const id of this.languageModelsService.getLanguageModelIds()) {
const meta = this.languageModelsService.lookupLanguageModel(id);
if (meta && ILanguageModelChatMetadata.matchesQualifiedName(modelName, meta)) {
if (meta && ILanguageModelChatMetadata.matchesQualifiedName(node.value.value, meta)) {
const lines: string[] = [];
lines.push(baseMessage + '\n');
lines.push(localize('modelName', '- Name: {0}', meta.name));
@@ -175,13 +157,11 @@ export class PromptHeaderHoverProvider extends Disposable implements HoverProvid
return this.createHover(baseMessage, range);
}
private getModeHover(mode: PromptModeMetadata, position: Position, baseMessage: string): Hover | undefined {
private getModeHover(mode: IHeaderAttribute, position: Position, baseMessage: string): Hover | undefined {
const lines: string[] = [];
const value = mode.value;
if (value && mode.valueRange?.containsPosition(position)) {
const mode = this.chatModeService.findModeByName(value);
if (value.type === 'string' && value.range.containsPosition(position)) {
const mode = this.chatModeService.findModeByName(value.value);
if (mode) {
const description = mode.description.get() || (isBuiltinChatMode(mode) ? localize('promptHeader.prompt.mode.builtInDesc', 'Built-in chat mode') : localize('promptHeader.prompt.mode.customDesc', 'Custom chat mode'));
lines.push(`\`${mode.name}\`: ${description}`);

View File

@@ -4,67 +4,42 @@
*--------------------------------------------------------------------------------------------*/
import { IPromptsService } from '../service/promptsService.js';
import { assert } from '../../../../../../base/common/assert.js';
import { ITextModel } from '../../../../../../editor/common/model.js';
import { assertDefined } from '../../../../../../base/common/types.js';
import { CancellationError } from '../../../../../../base/common/errors.js';
import { CancellationToken } from '../../../../../../base/common/cancellation.js';
import { ILink, ILinksList, LinkProvider } from '../../../../../../editor/common/languages.js';
import { ALL_PROMPTS_LANGUAGE_SELECTOR } from '../promptTypes.js';
import { Disposable } from '../../../../../../base/common/lifecycle.js';
import { ILanguageFeaturesService } from '../../../../../../editor/common/services/languageFeatures.js';
/**
* Provides link references for prompt files.
*/
export class PromptLinkProvider implements LinkProvider {
export class PromptLinkProvider extends Disposable implements LinkProvider {
constructor(
@ILanguageFeaturesService languageService: ILanguageFeaturesService,
@IPromptsService private readonly promptsService: IPromptsService,
) {
super();
this._register(languageService.linkProvider.register(ALL_PROMPTS_LANGUAGE_SELECTOR, this));
}
/**
* Provide list of links for the provided text model.
*/
public async provideLinks(
model: ITextModel,
token: CancellationToken,
): Promise<ILinksList | undefined> {
assert(
!token.isCancellationRequested,
new CancellationError(),
);
const parser = this.promptsService.getSyntaxParserFor(model);
assert(
parser.isDisposed === false,
'Prompt parser must not be disposed.',
);
// start the parser in case it was not started yet,
// and wait for it to settle to a final result
const completed = await parser.start(token).settled();
if (!completed || token.isCancellationRequested) {
return undefined;
public async provideLinks(model: ITextModel, token: CancellationToken): Promise<ILinksList | undefined> {
const parser = this.promptsService.getParsedPromptFile(model);
if (!parser.body) {
return;
}
const { references } = parser;
// filter out references that are not valid links
const links: ILink[] = references
.map((reference) => {
const { uri, linkRange } = reference;
// must always be true because of the filter above
assertDefined(
linkRange,
'Link range must be defined.',
);
return {
range: linkRange,
url: uri,
};
});
return {
links,
};
const links: ILink[] = [];
for (const ref of parser.body.fileReferences) {
if (!ref.isMarkdownLink) {
const url = parser.body.resolveFilePath(ref.content);
if (url) {
links.push({ range: ref.range, url });
}
}
}
return { links };
}
}

View File

@@ -8,12 +8,11 @@ import { Registry } from '../../../../../platform/registry/common/platform.js';
import { LifecyclePhase } from '../../../../services/lifecycle/common/lifecycle.js';
import { IWorkbenchContributionsRegistry, Extensions, IWorkbenchContribution } from '../../../../common/contributions.js';
import { PromptLinkProvider } from './languageProviders/promptLinkProvider.js';
import { PromptLinkDiagnosticsInstanceManager } from './languageProviders/promptLinkDiagnosticsProvider.js';
import { PromptHeaderDiagnosticsInstanceManager } from './languageProviders/promptHeaderDiagnosticsProvider.js';
import { PromptBodyAutocompletion } from './languageProviders/promptBodyAutocompletion.js';
import { PromptHeaderAutocompletion } from './languageProviders/promptHeaderAutocompletion.js';
import { PromptHeaderHoverProvider } from './languageProviders/promptHeaderHovers.js';
import { PromptHeaderDefinitionProvider } from './languageProviders/PromptHeaderDefinitionProvider.js';
import { PromptValidatorContribution } from './service/promptValidator.js';
/**
@@ -24,15 +23,7 @@ export function registerPromptFileContributions(): void {
// all language constributions
registerContribution(PromptLinkProvider);
registerContribution(PromptLinkDiagnosticsInstanceManager);
registerContribution(PromptHeaderDiagnosticsInstanceManager);
/**
* PromptDecorationsProviderInstanceManager is currently disabled because the only currently
* available decoration is the Front Matter header, which we decided to disable for now.
* Add it back when more decorations are needed.
*/
// registerContribution(PromptDecorationsProviderInstanceManager); ,
registerContribution(PromptValidatorContribution);
registerContribution(PromptBodyAutocompletion);
registerContribution(PromptHeaderAutocompletion);

View File

@@ -0,0 +1,308 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Iterable } from '../../../../../../base/common/iterator.js';
import { dirname, joinPath } from '../../../../../../base/common/resources.js';
import { splitLinesIncludeSeparators } from '../../../../../../base/common/strings.js';
import { URI } from '../../../../../../base/common/uri.js';
import { parse, YamlNode, YamlParseError, Position as YamlPosition } from '../../../../../../base/common/yaml.js';
import { Range } from '../../../../../../editor/common/core/range.js';
import { chatVariableLeader } from '../../chatParserTypes.js';
export class NewPromptsParser {
constructor() {
}
public parse(uri: URI, content: string): ParsedPromptFile {
const linesWithEOL = splitLinesIncludeSeparators(content);
if (linesWithEOL.length === 0) {
return new ParsedPromptFile(uri, undefined, undefined);
}
let header: PromptHeader | undefined = undefined;
let body: PromptBody | undefined = undefined;
let bodyStartLine = 0;
if (linesWithEOL[0].match(/^---[\s\r\n]*$/)) {
let headerEndLine = linesWithEOL.findIndex((line, index) => index > 0 && line.match(/^---[\s\r\n]*$/));
if (headerEndLine === -1) {
headerEndLine = linesWithEOL.length;
bodyStartLine = linesWithEOL.length;
} else {
bodyStartLine = headerEndLine + 1;
}
// range starts on the line after the ---, and ends at the beginning of the line that has the closing ---
const range = new Range(2, 1, headerEndLine + 1, 1);
header = new PromptHeader(range, linesWithEOL);
}
if (bodyStartLine < linesWithEOL.length) {
// range starts on the line after the ---, and ends at the beginning of line after the last line
const range = new Range(bodyStartLine + 1, 1, linesWithEOL.length + 1, 1);
body = new PromptBody(range, linesWithEOL, uri);
}
return new ParsedPromptFile(uri, header, body);
}
}
export class ParsedPromptFile {
constructor(public readonly uri: URI, public readonly header?: PromptHeader, public readonly body?: PromptBody) {
}
}
export interface ParseError {
readonly message: string;
readonly range: Range;
readonly code: string;
}
interface ParsedHeader {
readonly node: YamlNode | undefined;
readonly errors: ParseError[];
readonly attributes: IHeaderAttribute[];
}
export class PromptHeader {
private _parsed: ParsedHeader | undefined;
constructor(public readonly range: Range, private readonly linesWithEOL: string[]) {
}
private get _parsedHeader(): ParsedHeader {
if (this._parsed === undefined) {
const yamlErrors: YamlParseError[] = [];
const lines = this.linesWithEOL.slice(this.range.startLineNumber - 1, this.range.endLineNumber - 1).join('');
const node = parse(lines, yamlErrors);
const attributes = [];
const errors: ParseError[] = yamlErrors.map(err => ({ message: err.message, range: this.asRange(err), code: err.code }));
if (node?.type === 'object') {
for (const property of node.properties) {
attributes.push({
key: property.key.value,
range: this.asRange({ start: property.key.start, end: property.value.end }),
value: this.asValue(property.value)
});
}
} else {
errors.push({ message: 'Invalid header, expecting <key: value> pairs', range: this.range, code: 'INVALID_YAML' });
}
this._parsed = { node, attributes, errors };
}
return this._parsed;
}
private asRange({ start, end }: { start: YamlPosition; end: YamlPosition }): Range {
return new Range(this.range.startLineNumber + start.line, start.character + 1, this.range.startLineNumber + end.line, end.character + 1);
}
private asValue(node: YamlNode): IValue {
switch (node.type) {
case 'string':
return { type: 'string', value: node.value, range: this.asRange(node) };
case 'number':
return { type: 'number', value: node.value, range: this.asRange(node) };
case 'boolean':
return { type: 'boolean', value: node.value, range: this.asRange(node) };
case 'null':
return { type: 'null', value: node.value, range: this.asRange(node) };
case 'array':
return { type: 'array', items: node.items.map(item => this.asValue(item)), range: this.asRange(node) };
case 'object': {
const properties = node.properties.map(property => ({ key: this.asValue(property.key) as IStringValue, value: this.asValue(property.value) }));
return { type: 'object', properties, range: this.asRange(node) };
}
}
}
public get attributes(): IHeaderAttribute[] {
return this._parsedHeader.attributes;
}
public getAttribute(key: string): IHeaderAttribute | undefined {
return this._parsedHeader.attributes.find(attr => attr.key === key);
}
public get errors(): ParseError[] {
return this._parsedHeader.errors;
}
private getStringAttribute(key: string): string | undefined {
const attribute = this._parsedHeader.attributes.find(attr => attr.key === key);
if (attribute?.value.type === 'string') {
return attribute.value.value;
}
return undefined;
}
public get description(): string | undefined {
return this.getStringAttribute('description');
}
public get mode(): string | undefined {
return this.getStringAttribute('mode');
}
public get model(): string | undefined {
return this.getStringAttribute('model');
}
public get applyTo(): string | undefined {
return this.getStringAttribute('applyTo');
}
public get tools(): string[] | undefined {
const toolsAttribute = this._parsedHeader.attributes.find(attr => attr.key === 'tools');
if (!toolsAttribute) {
return undefined;
}
if (toolsAttribute.value.type === 'array') {
const tools: string[] = [];
for (const item of toolsAttribute.value.items) {
if (item.type === 'string' && item.value) {
tools.push(item.value);
}
}
return tools;
} else if (toolsAttribute.value.type === 'object') {
const tools: string[] = [];
const collectLeafs = ({ key, value }: { key: IStringValue; value: IValue }) => {
if (value.type === 'boolean') {
tools.push(key.value);
} else if (value.type === 'object') {
value.properties.forEach(collectLeafs);
}
};
toolsAttribute.value.properties.forEach(collectLeafs);
return tools;
}
return undefined;
}
}
export interface IHeaderAttribute {
readonly range: Range;
readonly key: string;
readonly value: IValue;
}
export interface IStringValue { readonly type: 'string'; readonly value: string; readonly range: Range }
export interface INumberValue { readonly type: 'number'; readonly value: number; readonly range: Range }
export interface INullValue { readonly type: 'null'; readonly value: null; readonly range: Range }
export interface IBooleanValue { readonly type: 'boolean'; readonly value: boolean; readonly range: Range }
export interface IArrayValue {
readonly type: 'array';
readonly items: readonly IValue[];
readonly range: Range;
}
export interface IObjectValue {
readonly type: 'object';
readonly properties: { key: IStringValue; value: IValue }[];
readonly range: Range;
}
export type IValue = IStringValue | INumberValue | IBooleanValue | IArrayValue | IObjectValue | INullValue;
interface ParsedBody {
readonly fileReferences: readonly IBodyFileReference[];
readonly variableReferences: readonly IBodyVariableReference[];
readonly bodyOffset: number;
}
export class PromptBody {
private _parsed: ParsedBody | undefined;
constructor(public readonly range: Range, private readonly linesWithEOL: string[], public readonly uri: URI) {
}
public get fileReferences(): readonly IBodyFileReference[] {
return this.getParsedBody().fileReferences;
}
public get variableReferences(): readonly IBodyVariableReference[] {
return this.getParsedBody().variableReferences;
}
public get offset(): number {
return this.getParsedBody().bodyOffset;
}
private getParsedBody(): ParsedBody {
if (this._parsed === undefined) {
const markdownLinkRanges: Range[] = [];
const fileReferences: IBodyFileReference[] = [];
const variableReferences: IBodyVariableReference[] = [];
const bodyOffset = Iterable.reduce(Iterable.slice(this.linesWithEOL, 0, this.range.startLineNumber - 1), (len, line) => line.length + len, 0);
for (let i = this.range.startLineNumber - 1, lineStartOffset = bodyOffset; i < this.range.endLineNumber - 1; i++) {
const line = this.linesWithEOL[i];
const linkMatch = line.matchAll(/\[(.*?)\]\((.+?)\)/g);
for (const match of linkMatch) {
const linkEndOffset = match.index + match[0].length - 1; // before the parenthesis
const linkStartOffset = match.index + match[0].length - match[2].length - 1;
const range = new Range(i + 1, linkStartOffset + 1, i + 1, linkEndOffset + 1);
fileReferences.push({ content: match[2], range, isMarkdownLink: true });
markdownLinkRanges.push(new Range(i + 1, match.index + 1, i + 1, match.index + match[0].length + 1));
}
const reg = new RegExp(`${chatVariableLeader}([\\w]+:)?([^\\s#]+)`, 'g');
const matches = line.matchAll(reg);
for (const match of matches) {
const fullRange = new Range(i + 1, match.index + 1, i + 1, match.index + match[0].length + 1);
if (markdownLinkRanges.some(mdRange => Range.areIntersectingOrTouching(mdRange, fullRange))) {
continue;
}
const varType = match[1];
if (varType) {
if (varType === 'file:') {
const linkStartOffset = match.index + match[0].length - match[2].length;
const linkEndOffset = match.index + match[0].length;
const range = new Range(i + 1, linkStartOffset + 1, i + 1, linkEndOffset + 1);
fileReferences.push({ content: match[2], range, isMarkdownLink: false });
}
} else {
const contentStartOffset = match.index + 1; // after the #
const contentEndOffset = match.index + match[0].length;
const range = new Range(i + 1, contentStartOffset + 1, i + 1, contentEndOffset + 1);
variableReferences.push({ name: match[2], range, offset: lineStartOffset + match.index });
}
}
lineStartOffset += line.length;
}
this._parsed = { fileReferences: fileReferences.sort((a, b) => Range.compareRangesUsingStarts(a.range, b.range)), variableReferences, bodyOffset };
}
return this._parsed;
}
public getContent(): string {
return this.linesWithEOL.slice(this.range.startLineNumber - 1, this.range.endLineNumber - 1).join('');
}
public resolveFilePath(path: string): URI | undefined {
try {
if (path.startsWith('/')) {
return this.uri.with({ path });
} else if (path.match(/^[a-zA-Z]:\\/)) {
return URI.parse(path);
} else {
const dirName = dirname(this.uri);
return joinPath(dirName, path);
}
} catch {
return undefined;
}
}
}
export interface IBodyFileReference {
readonly content: string;
readonly range: Range;
readonly isMarkdownLink: boolean;
}
export interface IBodyVariableReference {
readonly name: string;
readonly range: Range;
readonly offset: number;
}

View File

@@ -0,0 +1,409 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { isEmptyPattern, parse, splitGlobAware } from '../../../../../../base/common/glob.js';
import { Iterable } from '../../../../../../base/common/iterator.js';
import { Range } from '../../../../../../editor/common/core/range.js';
import { ITextModel } from '../../../../../../editor/common/model.js';
import { IModelService } from '../../../../../../editor/common/services/model.js';
import { localize } from '../../../../../../nls.js';
import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js';
import { IMarkerData, IMarkerService, MarkerSeverity } from '../../../../../../platform/markers/common/markers.js';
import { IChatMode, IChatModeService } from '../../chatModes.js';
import { ChatModeKind } from '../../constants.js';
import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js';
import { ILanguageModelToolsService } from '../../languageModelToolsService.js';
import { getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js';
import { IHeaderAttribute, ParsedPromptFile } from './newPromptsParser.js';
import { PromptsConfig } from '../config/config.js';
import { Disposable, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js';
import { Delayer } from '../../../../../../base/common/async.js';
import { ResourceMap } from '../../../../../../base/common/map.js';
import { IFileService } from '../../../../../../platform/files/common/files.js';
import { IPromptsService } from './promptsService.js';
const MARKERS_OWNER_ID = 'prompts-diagnostics-provider';
export class PromptValidator {
constructor(
@ILanguageModelsService private readonly languageModelsService: ILanguageModelsService,
@ILanguageModelToolsService private readonly languageModelToolsService: ILanguageModelToolsService,
@IChatModeService private readonly chatModeService: IChatModeService,
@IFileService private readonly fileService: IFileService,
) { }
public async validate(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): Promise<void> {
promptAST.header?.errors.forEach(error => report(toMarker(error.message, error.range, MarkerSeverity.Error)));
this.validateHeader(promptAST, promptType, report);
await this.validateBody(promptAST, report);
}
private async validateBody(promptAST: ParsedPromptFile, report: (markers: IMarkerData) => void): Promise<void> {
const body = promptAST.body;
if (!body) {
return;
}
// Validate file references
const fileReferenceChecks: Promise<void>[] = [];
for (const ref of body.fileReferences) {
const resolved = body.resolveFilePath(ref.content);
if (!resolved) {
report(toMarker(localize('promptValidator.invalidFileReference', "Invalid file reference '{0}'.", ref.content), ref.range, MarkerSeverity.Warning));
continue;
}
fileReferenceChecks.push((async () => {
try {
const exists = await this.fileService.exists(resolved);
if (!exists) {
report(toMarker(localize('promptValidator.fileNotFound', "File '{0}' not found.", ref.content), ref.range, MarkerSeverity.Warning));
}
} catch {
report(toMarker(localize('promptValidator.fileNotFound', "File '{0}' not found.", ref.content), ref.range, MarkerSeverity.Warning));
}
})());
}
// Validate variable references (tool or toolset names)
if (body.variableReferences.length) {
const available = this.getAvailableToolAndToolSetNames();
for (const variable of body.variableReferences) {
if (!available.has(variable.name)) {
report(toMarker(localize('promptValidator.unknownVariableReference', "Unknown tool or toolset '{0}'.", variable.name), variable.range, MarkerSeverity.Warning));
}
}
}
await Promise.all(fileReferenceChecks);
}
private validateHeader(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): void {
const header = promptAST.header;
if (!header) {
return;
}
const validAttributeNames = getValidAttributeNames(promptType);
const attributes = header.attributes;
for (const attribute of attributes) {
if (!validAttributeNames.includes(attribute.key)) {
switch (promptType) {
case PromptsType.prompt:
report(toMarker(localize('promptValidator.unknownAttribute.prompt', "Attribute '{0}' is not supported in prompt files. Supported: {1}", attribute.key, validAttributeNames.join(', ')), attribute.range, MarkerSeverity.Warning));
break;
case PromptsType.mode:
report(toMarker(localize('promptValidator.unknownAttribute.mode', "Attribute '{0}' is not supported in mode files. Supported: {1}", attribute.key, validAttributeNames.join(', ')), attribute.range, MarkerSeverity.Warning));
break;
case PromptsType.instructions:
report(toMarker(localize('promptValidator.unknownAttribute.instructions', "Attribute '{0}' is not supported in instructions files. Supported: {1}", attribute.key, validAttributeNames.join(', ')), attribute.range, MarkerSeverity.Warning));
break;
}
}
}
this.validateDescription(attributes, report);
switch (promptType) {
case PromptsType.prompt: {
const mode = this.validateMode(attributes, report);
this.validateTools(attributes, mode?.kind ?? ChatModeKind.Agent, report);
this.validateModel(attributes, mode?.kind ?? ChatModeKind.Agent, report);
break;
}
case PromptsType.instructions:
this.validateApplyTo(attributes, report);
break;
case PromptsType.mode:
this.validateTools(attributes, ChatModeKind.Agent, report);
this.validateModel(attributes, ChatModeKind.Agent, report);
break;
}
}
private validateDescription(attributes: IHeaderAttribute[], report: (markers: IMarkerData) => void): void {
const descriptionAttribute = attributes.find(attr => attr.key === 'description');
if (!descriptionAttribute) {
return;
}
if (descriptionAttribute.value.type !== 'string') {
report(toMarker(localize('promptValidator.descriptionMustBeString', "The 'description' attribute must be a string."), descriptionAttribute.range, MarkerSeverity.Error));
return;
}
if (descriptionAttribute.value.value.trim().length === 0) {
report(toMarker(localize('promptValidator.descriptionShouldNotBeEmpty', "The 'description' attribute should not be empty."), descriptionAttribute.value.range, MarkerSeverity.Error));
return;
}
}
private validateModel(attributes: IHeaderAttribute[], modeKind: ChatModeKind, report: (markers: IMarkerData) => void): void {
const attribute = attributes.find(attr => attr.key === 'model');
if (!attribute) {
return;
}
if (attribute.value.type !== 'string') {
report(toMarker(localize('promptValidator.modelMustBeString', "The 'model' attribute must be a string."), attribute.value.range, MarkerSeverity.Error));
return;
}
const modelName = attribute.value.value.trim();
if (modelName.length === 0) {
report(toMarker(localize('promptValidator.modelMustBeNonEmpty', "The 'model' attribute must be a non-empty string."), attribute.value.range, MarkerSeverity.Error));
return;
}
const languageModes = this.languageModelsService.getLanguageModelIds();
if (languageModes.length === 0) {
// likely the service is not initialized yet
return;
}
const modelMetadata = this.findModelByName(languageModes, modelName);
if (!modelMetadata) {
report(toMarker(localize('promptValidator.modelNotFound', "Unknown model '{0}'.", modelName), attribute.value.range, MarkerSeverity.Warning));
} else if (modeKind === ChatModeKind.Agent && !ILanguageModelChatMetadata.suitableForAgentMode(modelMetadata)) {
report(toMarker(localize('promptValidator.modelNotSuited', "Model '{0}' is not suited for agent mode.", modelName), attribute.value.range, MarkerSeverity.Warning));
}
}
private findModelByName(languageModes: string[], modelName: string): ILanguageModelChatMetadata | undefined {
for (const model of languageModes) {
const metadata = this.languageModelsService.lookupLanguageModel(model);
if (metadata && metadata.isUserSelectable !== false && ILanguageModelChatMetadata.matchesQualifiedName(modelName, metadata)) {
return metadata;
}
}
return undefined;
}
private validateMode(attributes: IHeaderAttribute[], report: (markers: IMarkerData) => void): IChatMode | undefined {
const attribute = attributes.find(attr => attr.key === 'mode');
if (!attribute) {
return undefined; // default mode for prompts is Agent
}
if (attribute.value.type !== 'string') {
report(toMarker(localize('promptValidator.modeMustBeString', "The 'mode' attribute must be a string."), attribute.value.range, MarkerSeverity.Error));
return undefined;
}
const modeValue = attribute.value.value;
if (modeValue.trim().length === 0) {
report(toMarker(localize('promptValidator.modeMustBeNonEmpty', "The 'mode' attribute must be a non-empty string."), attribute.value.range, MarkerSeverity.Error));
return undefined;
}
const modes = this.chatModeService.getModes();
const availableModes = [];
// Check if mode exists in builtin or custom modes
for (const mode of Iterable.concat(modes.builtin, modes.custom)) {
if (mode.name === modeValue) {
return mode;
}
availableModes.push(mode.name); // collect all available mode names
}
const errorMessage = localize('promptValidator.modeNotFound', "Unknown mode '{0}'. Available modes: {1}.", modeValue, availableModes.join(', '));
report(toMarker(errorMessage, attribute.value.range, MarkerSeverity.Warning));
return undefined;
}
private validateTools(attributes: IHeaderAttribute[], modeKind: ChatModeKind, report: (markers: IMarkerData) => void): undefined {
const attribute = attributes.find(attr => attr.key === 'tools');
if (!attribute) {
return;
}
if (modeKind !== ChatModeKind.Agent) {
report(toMarker(localize('promptValidator.toolsOnlyInAgent', "The 'tools' attribute is only supported in agent mode. Attribute will be ignored."), attribute.range, MarkerSeverity.Warning));
}
if (attribute.value.type !== 'array') {
report(toMarker(localize('promptValidator.toolsMustBeArray', "The 'tools' attribute must be an array."), attribute.value.range, MarkerSeverity.Error));
return;
}
if (attribute.value.items.length > 0) {
const available = this.getAvailableToolAndToolSetNames();
for (const item of attribute.value.items) {
if (item.type !== 'string') {
report(toMarker(localize('promptValidator.eachToolMustBeString', "Each tool name in the 'tools' attribute must be a string."), item.range, MarkerSeverity.Error));
} else if (item.value && !available.has(item.value)) {
report(toMarker(localize('promptValidator.toolNotFound', "Unknown tool '{0}'.", item.value), item.range, MarkerSeverity.Warning));
}
}
}
}
private getAvailableToolAndToolSetNames(): Set<string> {
const available = new Set<string>();
for (const tool of this.languageModelToolsService.getTools()) {
if (tool.canBeReferencedInPrompt) {
available.add(tool.toolReferenceName ?? tool.displayName);
}
}
for (const toolSet of this.languageModelToolsService.toolSets.get()) {
available.add(toolSet.referenceName);
for (const tool of toolSet.getTools()) {
available.add(tool.toolReferenceName ?? tool.displayName);
}
}
return available;
}
private validateApplyTo(attributes: IHeaderAttribute[], report: (markers: IMarkerData) => void): undefined {
const attribute = attributes.find(attr => attr.key === 'applyTo');
if (!attribute) {
return;
}
if (attribute.value.type !== 'string') {
report(toMarker(localize('promptValidator.applyToMustBeString', "The 'applyTo' attribute must be a string."), attribute.value.range, MarkerSeverity.Error));
return;
}
const pattern = attribute.value.value;
try {
const patterns = splitGlobAware(pattern, ',');
if (patterns.length === 0) {
report(toMarker(localize('promptValidator.applyToMustBeValidGlob', "The 'applyTo' attribute must be a valid glob pattern."), attribute.value.range, MarkerSeverity.Error));
return;
}
for (const pattern of patterns) {
const globPattern = parse(pattern);
if (isEmptyPattern(globPattern)) {
report(toMarker(localize('promptValidator.applyToMustBeValidGlob', "The 'applyTo' attribute must be a valid glob pattern."), attribute.value.range, MarkerSeverity.Error));
return;
}
}
} catch (_error) {
report(toMarker(localize('promptValidator.applyToMustBeValidGlob', "The 'applyTo' attribute must be a valid glob pattern."), attribute.value.range, MarkerSeverity.Error));
}
}
}
function getValidAttributeNames(promptType: PromptsType): string[] {
switch (promptType) {
case PromptsType.prompt:
return ['description', 'model', 'tools', 'mode'];
case PromptsType.instructions:
return ['description', 'applyTo'];
case PromptsType.mode:
return ['description', 'model', 'tools'];
}
}
function toMarker(message: string, range: Range, severity = MarkerSeverity.Error): IMarkerData {
return { severity, message, ...range };
}
export class PromptValidatorContribution extends Disposable {
private readonly validator: PromptValidator;
private readonly localDisposables = this._register(new DisposableStore());
constructor(
@IModelService private modelService: IModelService,
@IInstantiationService instantiationService: IInstantiationService,
@IConfigurationService private configService: IConfigurationService,
@IMarkerService private readonly markerService: IMarkerService,
@IPromptsService private readonly promptsService: IPromptsService,
@ILanguageModelsService private readonly languageModelsService: ILanguageModelsService,
@ILanguageModelToolsService private readonly languageModelToolsService: ILanguageModelToolsService,
@IChatModeService private readonly chatModeService: IChatModeService,
) {
super();
this.validator = instantiationService.createInstance(PromptValidator);
this.updateRegistration();
this._register(this.configService.onDidChangeConfiguration(e => {
if (e.affectsConfiguration(PromptsConfig.KEY)) {
this.updateRegistration();
}
}));
}
updateRegistration(): void {
this.localDisposables.clear();
if (!PromptsConfig.enabled(this.configService)) {
return;
}
const trackers = new ResourceMap<ModelTracker>();
this.localDisposables.add(toDisposable(() => {
trackers.forEach(tracker => tracker.dispose());
}));
const validateAllDelayer = this._register(new Delayer<void>(200));
const validateAll = (): void => {
validateAllDelayer.trigger(async () => {
this.modelService.getModels().forEach(model => {
const promptType = getPromptsTypeForLanguageId(model.getLanguageId());
if (promptType) {
trackers.set(model.uri, new ModelTracker(model, promptType, this.validator, this.promptsService, this.markerService));
}
});
});
};
this.localDisposables.add(this.modelService.onModelAdded((model) => {
const promptType = getPromptsTypeForLanguageId(model.getLanguageId());
if (promptType) {
trackers.set(model.uri, new ModelTracker(model, promptType, this.validator, this.promptsService, this.markerService));
}
}));
this.localDisposables.add(this.modelService.onModelRemoved((model) => {
const promptType = getPromptsTypeForLanguageId(model.getLanguageId());
if (promptType) {
const tracker = trackers.get(model.uri);
if (tracker) {
tracker.dispose();
trackers.delete(model.uri);
}
}
}));
this.localDisposables.add(this.modelService.onModelLanguageChanged((event) => {
const { model } = event;
const tracker = trackers.get(model.uri);
if (tracker) {
tracker.dispose();
trackers.delete(model.uri);
}
const promptType = getPromptsTypeForLanguageId(model.getLanguageId());
if (promptType) {
trackers.set(model.uri, new ModelTracker(model, promptType, this.validator, this.promptsService, this.markerService));
}
}));
this.localDisposables.add(this.languageModelToolsService.onDidChangeTools(() => validateAll()));
this.localDisposables.add(this.chatModeService.onDidChangeChatModes(() => validateAll()));
this.localDisposables.add(this.languageModelsService.onDidChangeLanguageModels(() => validateAll()));
validateAll();
}
}
class ModelTracker extends Disposable {
private readonly delayer: Delayer<void>;
constructor(
private readonly textModel: ITextModel,
private readonly promptType: PromptsType,
private readonly validator: PromptValidator,
@IPromptsService private readonly promptsService: IPromptsService,
@IMarkerService private readonly markerService: IMarkerService,
) {
super();
this.delayer = this._register(new Delayer<void>(200));
this._register(textModel.onDidChangeContent(() => this.validate()));
this.validate();
}
private validate(): void {
this.delayer.trigger(async () => {
const markers: IMarkerData[] = [];
const ast = this.promptsService.getParsedPromptFile(this.textModel);
await this.validator.validate(ast, this.promptType, m => markers.push(m));
this.markerService.changeOne(MARKERS_OWNER_ID, this.textModel.uri, markers);
});
}
public override dispose() {
this.markerService.remove(MARKERS_OWNER_ID, [this.textModel.uri]);
super.dispose();
}
}

View File

@@ -13,8 +13,9 @@ 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';
import { YamlNode, YamlParseError } from '../../../../../../base/common/yaml.js';
import { IVariableReference } from '../../chatModes.js';
import { ParsedPromptFile } from './newPromptsParser.js';
/**
* Provides prompt services.
@@ -164,6 +165,12 @@ export interface IPromptsService extends IDisposable {
*/
getSyntaxParserFor(model: ITextModel): TSharedPrompt & { isDisposed: false };
/**
* The parsed prompt file for the provided text model.
* @param textModel Returns the parsed prompt file.
*/
getParsedPromptFile(textModel: ITextModel): ParsedPromptFile;
/**
* List all available prompt files.
*/
@@ -183,7 +190,7 @@ export interface IPromptsService extends IDisposable {
/**
* Gets the prompt file for a slash command.
*/
resolvePromptSlashCommand(data: IChatPromptSlashCommand, _token: CancellationToken): Promise<IPromptParserResult | undefined>;
resolvePromptSlashCommand(data: IChatPromptSlashCommand, _token: CancellationToken): Promise<ParsedPromptFile | undefined>;
/**
* Returns a prompt command if the command name is valid.
@@ -206,6 +213,12 @@ export interface IPromptsService extends IDisposable {
*/
parse(uri: URI, type: PromptsType, token: CancellationToken): Promise<IPromptParserResult>;
/**
* Parses the provided URI
* @param uris
*/
parseNew(uri: URI, token: CancellationToken): Promise<ParsedPromptFile>;
/**
* Returns the prompt file type for the given URI.
* @param resource the URI of the resource
@@ -223,7 +236,12 @@ export interface IChatPromptSlashCommand {
export interface IPromptParserResult {
readonly uri: URI;
readonly metadata: TMetadata | null;
readonly topError: ITopError | undefined;
readonly fileReferences: readonly URI[];
readonly variableReferences: readonly IVariableReference[];
readonly header?: IPromptHeader;
}
export interface IPromptHeader {
readonly node: YamlNode | undefined;
readonly errors: YamlParseError[];
}

View File

@@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import { localize } from '../../../../../../nls.js';
import { getLanguageIdForPromptsType, getPromptsTypeForLanguageId, MODE_LANGUAGE_ID, PROMPT_LANGUAGE_ID, PromptsType } from '../promptTypes.js';
import { getLanguageIdForPromptsType, getPromptsTypeForLanguageId, PROMPT_LANGUAGE_ID, PromptsType } from '../promptTypes.js';
import { PromptParser } from '../parsers/promptParser.js';
import { type URI } from '../../../../../../base/common/uri.js';
import { assert } from '../../../../../../base/common/assert.js';
@@ -27,6 +27,12 @@ import { ILanguageService } from '../../../../../../editor/common/languages/lang
import { PromptsConfig } from '../config/config.js';
import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
import { PositionOffsetTransformer } from '../../../../../../editor/common/core/text/positionToOffset.js';
import { NewPromptsParser, ParsedPromptFile } from './newPromptsParser.js';
import { IFileService } from '../../../../../../platform/files/common/files.js';
import { ResourceMap } from '../../../../../../base/common/map.js';
import { CancellationError } from '../../../../../../base/common/errors.js';
import { OffsetRange } from '../../../../../../editor/common/core/ranges/offsetRange.js';
import { IVariableReference } from '../../chatModes.js';
/**
* Provides prompt services.
@@ -49,6 +55,9 @@ export class PromptsService extends Disposable implements IPromptsService {
*/
private cachedCustomChatModes: Promise<readonly ICustomChatMode[]> | undefined;
private parsedPromptFileCache = new ResourceMap<[number, ParsedPromptFile]>();
/**
* Lazily created event that is fired when the custom chat modes change.
*/
@@ -62,6 +71,7 @@ export class PromptsService extends Disposable implements IPromptsService {
@IUserDataProfileService private readonly userDataService: IUserDataProfileService,
@ILanguageService private readonly languageService: ILanguageService,
@IConfigurationService private readonly configurationService: IConfigurationService,
@IFileService private readonly fileService: IFileService,
) {
super();
@@ -96,6 +106,10 @@ export class PromptsService extends Disposable implements IPromptsService {
return parser;
})
);
this._register(this.modelService.onModelRemoved((model) => {
this.parsedPromptFileCache.delete(model.uri);
}));
}
/**
@@ -133,6 +147,18 @@ export class PromptsService extends Disposable implements IPromptsService {
return this.cache.get(model);
}
public getParsedPromptFile(textModel: ITextModel): ParsedPromptFile {
const cached = this.parsedPromptFileCache.get(textModel.uri);
if (cached && cached[0] === textModel.getVersionId()) {
return cached[1];
}
const ast = new NewPromptsParser().parse(textModel.uri, textModel.getValue());
if (!cached || cached[0] < textModel.getVersionId()) {
this.parsedPromptFileCache.set(textModel.uri, [textModel.getVersionId(), ast]);
}
return ast;
}
public async listPromptFiles(type: PromptsType, token: CancellationToken): Promise<readonly IPromptPath[]> {
if (!PromptsConfig.enabled(this.configurationService)) {
return [];
@@ -171,13 +197,13 @@ export class PromptsService extends Disposable implements IPromptsService {
return undefined;
}
public async resolvePromptSlashCommand(data: IChatPromptSlashCommand, token: CancellationToken): Promise<IPromptParserResult | undefined> {
public async resolvePromptSlashCommand(data: IChatPromptSlashCommand, token: CancellationToken): Promise<ParsedPromptFile | undefined> {
const promptUri = await this.getPromptPath(data);
if (!promptUri) {
return undefined;
}
try {
return await this.parse(promptUri, PromptsType.prompt, token);
return await this.parseNew(promptUri, token);
} catch (error) {
this.logger.error(`[resolvePromptSlashCommand] Failed to parse prompt file: ${promptUri}`, error);
return undefined;
@@ -231,42 +257,28 @@ export class PromptsService extends Disposable implements IPromptsService {
const metadataList = await Promise.all(
modeFiles.map(async ({ uri }): Promise<ICustomChatMode> => {
let parser: PromptParser | undefined;
try {
// Note! this can be (and should be) improved by using shared parser instances
// that the `getSyntaxParserFor` method provides for opened documents.
parser = this.instantiationService.createInstance(
PromptParser,
uri,
{ allowNonPromptFiles: true, languageId: MODE_LANGUAGE_ID, updateOnChange: false },
).start(token);
const ast = await this.parseNew(uri, token);
const completed = await parser.settled();
if (!completed) {
throw new Error(localize('promptParser.notCompleted', "Prompt parser for {0} did not complete.", uri.toString()));
const variableReferences: IVariableReference[] = [];
let body = '';
if (ast.body) {
const bodyOffset = ast.body.offset;
const bodyVarRefs = ast.body.variableReferences;
for (let i = bodyVarRefs.length - 1; i >= 0; i--) { // in reverse order
const { name, offset } = bodyVarRefs[i];
const range = new OffsetRange(offset - bodyOffset, offset - bodyOffset + name.length + 1);
variableReferences.push({ name, range });
}
const body = await parser.getBody();
const nHeaderLines = parser.header?.range.endLineNumber ?? 0;
const transformer = new PositionOffsetTransformer(body);
const variableReferences = parser.variableReferences.map(ref => {
return {
name: ref.name,
range: transformer.getOffsetRange(ref.range.delta(-nHeaderLines))
};
}).sort((a, b) => b.range.start - a.range.start); // in reverse order
const name = getCleanPromptName(uri);
const metadata = parser.metadata;
if (metadata?.promptType !== PromptsType.mode) {
return { uri, name, body, variableReferences };
}
const { description, model, tools } = metadata;
return { uri, name, description, model, tools, body, variableReferences };
} finally {
parser?.dispose();
body = ast.body.getContent();
}
const name = getCleanPromptName(uri);
if (!ast.header) {
return { uri, name, body, variableReferences };
}
const { description, model, tools } = ast.header;
return { uri, name, description, model, tools, body, variableReferences };
})
);
@@ -294,14 +306,25 @@ export class PromptsService extends Disposable implements IPromptsService {
return {
uri: parser.uri,
metadata: parser.metadata,
topError: parser.topError,
variableReferences,
fileReferences: parser.references.map(ref => ref.uri)
fileReferences: parser.references.map(ref => ref.uri),
};
} finally {
parser?.dispose();
}
}
public async parseNew(uri: URI, token: CancellationToken): Promise<ParsedPromptFile> {
const model = this.modelService.getModel(uri);
if (model) {
return this.getParsedPromptFile(model);
}
const fileContent = await this.fileService.readFile(uri);
if (token.isCancellationRequested) {
throw new CancellationError();
}
return new NewPromptsParser().parse(uri, fileContent.value.toString());
}
}
export function getPromptCommandName(path: string): string {

View File

@@ -10,10 +10,10 @@ import { ChatMode, IChatMode, IChatModeService } from '../../common/chatModes.js
export class MockChatModeService implements IChatModeService {
readonly _serviceBrand: undefined;
private _modes: { builtin: readonly IChatMode[]; custom: readonly IChatMode[] } = { builtin: [ChatMode.Ask], custom: [] };
public readonly onDidChangeChatModes = Event.None;
constructor(private readonly _modes: { builtin: readonly IChatMode[]; custom: readonly IChatMode[] } = { builtin: [ChatMode.Ask], custom: [] }) { }
getModes(): { builtin: readonly IChatMode[]; custom: readonly IChatMode[] } {
return this._modes;
}

View File

@@ -6,6 +6,8 @@
import { CancellationToken } from '../../../../../base/common/cancellation.js';
import { Emitter } from '../../../../../base/common/event.js';
import { URI } from '../../../../../base/common/uri.js';
import { ITextModel } from '../../../../../editor/common/model.js';
import { ParsedPromptFile } from '../../common/promptSyntax/service/newPromptsParser.js';
import { ICustomChatMode, IPromptsService } from '../../common/promptSyntax/service/promptsService.js';
export class MockPromptsService implements IPromptsService {
@@ -33,6 +35,8 @@ export class MockPromptsService implements IPromptsService {
resolvePromptSlashCommand(_data: any, _token: CancellationToken): Promise<any> { throw new Error('Not implemented'); }
findPromptSlashCommands(): Promise<any[]> { throw new Error('Not implemented'); }
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'); }
getPromptFileType(_resource: URI): any { return undefined; }
getParsedPromptFile(textModel: ITextModel): ParsedPromptFile { throw new Error('Not implemented'); }
dispose(): void { }
}

View File

@@ -4,11 +4,8 @@
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { timeout } from '../../../../../../base/common/async.js';
import { Disposable } from '../../../../../../base/common/lifecycle.js';
import { Schemas } from '../../../../../../base/common/network.js';
import { URI } from '../../../../../../base/common/uri.js';
import { randomBoolean } from '../../../../../../base/test/common/testUtils.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js';
import { Range } from '../../../../../../editor/common/core/range.js';
import { ILanguageService } from '../../../../../../editor/common/languages/language.js';
@@ -23,14 +20,10 @@ import { TestInstantiationService } from '../../../../../../platform/instantiati
import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js';
import { NullPolicyService } from '../../../../../../platform/policy/common/policy.js';
import { ChatModeKind } from '../../../common/constants.js';
import { MarkdownLink } from '../../../common/promptSyntax/codecs/base/markdownCodec/tokens/markdownLink.js';
import { FileReference } from '../../../common/promptSyntax/codecs/tokens/fileReference.js';
import { getPromptFileType } from '../../../common/promptSyntax/config/promptFileLocations.js';
import { PromptsType } from '../../../common/promptSyntax/promptTypes.js';
import { type TErrorCondition } from '../../../common/promptSyntax/parsers/basePromptParser.js';
import { FilePromptParser } from '../../../common/promptSyntax/parsers/filePromptParser.js';
import { type TPromptReference } from '../../../common/promptSyntax/parsers/types.js';
import { IMockFolder, MockFilesystem } from './testUtils/mockFilesystem.js';
import { IBodyFileReference, NewPromptsParser } from '../../../common/promptSyntax/service/newPromptsParser.js';
/**
* Represents a file reference with an expected
@@ -44,19 +37,18 @@ class ExpectedReference {
constructor(
dirname: URI,
public readonly linkToken: FileReference | MarkdownLink,
public readonly errorCondition?: TErrorCondition,
public readonly ref: IBodyFileReference,
) {
this.uri = (linkToken.path.startsWith('/'))
? URI.file(linkToken.path)
: URI.joinPath(dirname, linkToken.path);
this.uri = (ref.content.startsWith('/'))
? URI.file(ref.content)
: URI.joinPath(dirname, ref.content);
}
/**
* Range of the underlying file reference token.
*/
public get range(): Range {
return this.linkToken.range;
return this.ref.range;
}
/**
@@ -67,6 +59,10 @@ class ExpectedReference {
}
}
function toUri(filePath: string): URI {
return URI.parse('testFs://' + filePath);
}
/**
* A reusable test utility to test the `PromptFileReference` class.
*/
@@ -82,80 +78,32 @@ class TestPromptFileReference extends Disposable {
// create in-memory file system
const fileSystemProvider = this._register(new InMemoryFileSystemProvider());
this._register(this.fileService.registerProvider(Schemas.file, fileSystemProvider));
this._register(this.fileService.registerProvider('testFs', fileSystemProvider));
}
/**
* Run the test.
*/
public async run(
): Promise<FilePromptParser> {
public async run(): Promise<any> {
// create the files structure on the disk
await (this.instantiationService.createInstance(MockFilesystem, this.fileStructure)).mock();
await (this.instantiationService.createInstance(MockFilesystem, this.fileStructure)).mock(toUri('/'));
// randomly test with and without delay to ensure that the file
// reference resolution is not susceptible to race conditions
if (randomBoolean()) {
await timeout(5);
}
const content = await this.fileService.readFile(this.rootFileUri);
// start resolving references for the specified root file
const rootReference = this._register(
this.instantiationService.createInstance(
FilePromptParser,
this.rootFileUri,
{ allowNonPromptFiles: true, languageId: undefined, updateOnChange: true },
),
).start();
// wait until entire prompts tree is resolved
await rootReference.settled();
const ast = new NewPromptsParser().parse(this.rootFileUri, content.value.toString());
assert(ast.body, 'Prompt file must have a body');
// resolve the root file reference including all nested references
const resolvedReferences: readonly (TPromptReference | undefined)[] = rootReference.references;
const resolvedReferences = ast.body.fileReferences ?? [];
for (let i = 0; i < this.expectedReferences.length; i++) {
const expectedReference = this.expectedReferences[i];
const resolvedReference = resolvedReferences[i];
if (expectedReference.linkToken instanceof MarkdownLink) {
assert(
resolvedReference?.subtype === 'markdown',
[
`Expected ${i}th resolved reference to be a markdown link`,
`got '${resolvedReference}'.`,
].join(', '),
);
}
if (expectedReference.linkToken instanceof FileReference) {
assert(
resolvedReference?.subtype === 'prompt',
[
`Expected ${i}th resolved reference to be a #file: link`,
`got '${resolvedReference}'.`,
].join(', '),
);
}
assert(
(resolvedReference) &&
(resolvedReference.uri.toString() === expectedReference.uri.toString()),
[
`Expected ${i}th resolved reference URI to be '${expectedReference.uri}'`,
`got '${resolvedReference?.uri}'.`,
].join(', '),
);
assert(
(resolvedReference) &&
(resolvedReference.range.equalsRange(expectedReference.range)),
[
`Expected ${i}th resolved reference range to be '${expectedReference.range}'`,
`got '${resolvedReference?.range}'.`,
].join(', '),
);
const resolvedUri = ast.body.resolveFilePath(resolvedReference.content);
assert.equal(resolvedUri?.fsPath, expectedReference.uri.fsPath);
assert.deepStrictEqual(resolvedReference.range, expectedReference.range);
}
assert.strictEqual(
@@ -167,7 +115,16 @@ class TestPromptFileReference extends Disposable {
].join('\n'),
);
return rootReference;
const result: any = {};
result.promptType = getPromptFileType(this.rootFileUri);
if (ast.header) {
for (const key of ['tools', 'model', 'mode', 'applyTo', 'description'] as const) {
if (ast.header[key]) {
result[key] = ast.header[key];
}
}
}
return result;
}
}
@@ -180,19 +137,34 @@ class TestPromptFileReference extends Disposable {
* @param lineNumber The expected line number of the file reference.
* @param startColumnNumber The expected start column number of the file reference.
*/
function createTestFileReference(
filePath: string,
lineNumber: number,
startColumnNumber: number,
): FileReference {
function createFileReference(filePath: string, lineNumber: number, startColumnNumber: number): IBodyFileReference {
const range = new Range(
lineNumber,
startColumnNumber,
startColumnNumber + '#file:'.length,
lineNumber,
startColumnNumber + `#file:${filePath}`.length,
startColumnNumber + '#file:'.length + filePath.length,
);
return new FileReference(range, filePath);
return {
range,
content: filePath,
isMarkdownLink: false,
};
}
function createMarkdownReference(lineNumber: number, startColumnNumber: number, firstSeg: string, secondSeg: string): IBodyFileReference {
const range = new Range(
lineNumber,
startColumnNumber + firstSeg.length + 1,
lineNumber,
startColumnNumber + firstSeg.length + secondSeg.length - 1,
);
return {
range,
content: secondSeg.substring(1, secondSeg.length - 1),
isMarkdownLink: true,
};
}
suite('PromptFileReference', function () {
@@ -225,7 +197,7 @@ suite('PromptFileReference', function () {
test('resolves nested file references', async function () {
const rootFolderName = 'resolves-nested-file-references';
const rootFolder = `/${rootFolderName}`;
const rootUri = URI.file(rootFolder);
const rootUri = toUri(rootFolder);
const test = testDisposables.add(instantiationService.createInstance(TestPromptFileReference,
/**
@@ -282,18 +254,18 @@ suite('PromptFileReference', function () {
/**
* The root file path to start the resolve process from.
*/
URI.file(`/${rootFolderName}/file2.prompt.md`),
toUri(`/${rootFolderName}/file2.prompt.md`),
/**
* The expected references to be resolved.
*/
[
new ExpectedReference(
rootUri,
createTestFileReference('folder1/file3.prompt.md', 2, 14),
createFileReference('folder1/file3.prompt.md', 2, 14),
),
new ExpectedReference(
rootUri,
new MarkdownLink(
createMarkdownReference(
3, 14,
'[file4.prompt.md]', '(./folder1/some-other-folder/file4.prompt.md)',
),
@@ -309,7 +281,7 @@ suite('PromptFileReference', function () {
test('tools', async function () {
const rootFolderName = 'resolves-nested-file-references';
const rootFolder = `/${rootFolderName}`;
const rootUri = URI.file(rootFolder);
const rootUri = toUri(rootFolder);
const test = testDisposables.add(instantiationService.createInstance(TestPromptFileReference,
/**
@@ -405,18 +377,18 @@ suite('PromptFileReference', function () {
/**
* The root file path to start the resolve process from.
*/
URI.file(`/${rootFolderName}/file2.prompt.md`),
toUri(`/${rootFolderName}/file2.prompt.md`),
/**
* The expected references to be resolved.
*/
[
new ExpectedReference(
rootUri,
createTestFileReference('folder1/file3.prompt.md', 7, 14),
createFileReference('folder1/file3.prompt.md', 7, 14),
),
new ExpectedReference(
rootUri,
new MarkdownLink(
createMarkdownReference(
8, 14,
'[file4.prompt.md]', '(./folder1/some-other-folder/file4.prompt.md)',
),
@@ -424,9 +396,7 @@ suite('PromptFileReference', function () {
]
));
const rootReference = await test.run();
const { metadata } = rootReference;
const metadata = await test.run();
assert.deepStrictEqual(
metadata,
@@ -445,7 +415,7 @@ suite('PromptFileReference', function () {
test('prompt language', async function () {
const rootFolderName = 'resolves-nested-file-references';
const rootFolder = `/${rootFolderName}`;
const rootUri = URI.file(rootFolder);
const rootUri = toUri(rootFolder);
const test = testDisposables.add(instantiationService.createInstance(TestPromptFileReference,
/**
@@ -513,18 +483,18 @@ suite('PromptFileReference', function () {
/**
* The root file path to start the resolve process from.
*/
URI.file(`/${rootFolderName}/file2.prompt.md`),
toUri(`/${rootFolderName}/file2.prompt.md`),
/**
* The expected references to be resolved.
*/
[
new ExpectedReference(
rootUri,
createTestFileReference('folder1/file3.prompt.md', 7, 14),
createFileReference('folder1/file3.prompt.md', 7, 14),
),
new ExpectedReference(
rootUri,
new MarkdownLink(
createMarkdownReference(
8, 14,
'[file4.prompt.md]', '(./folder1/some-other-folder/file4.prompt.md)',
),
@@ -532,17 +502,15 @@ suite('PromptFileReference', function () {
]
));
const rootReference = await test.run();
const { metadata } = rootReference;
const metadata = await test.run();
assert.deepStrictEqual(
metadata,
{
promptType: PromptsType.prompt,
mode: ChatModeKind.Agent,
description: 'Description of my prompt.',
tools: ['my-tool12'],
applyTo: '**/*',
},
'Must have correct metadata.',
);
@@ -553,7 +521,7 @@ suite('PromptFileReference', function () {
test('instructions language', async function () {
const rootFolderName = 'resolves-nested-file-references';
const rootFolder = `/${rootFolderName}`;
const rootUri = URI.file(rootFolder);
const rootUri = toUri(rootFolder);
const test = testDisposables.add(instantiationService.createInstance(TestPromptFileReference,
/**
@@ -621,18 +589,18 @@ suite('PromptFileReference', function () {
/**
* The root file path to start the resolve process from.
*/
URI.file(`/${rootFolderName}/file2.instructions.md`),
toUri(`/${rootFolderName}/file2.instructions.md`),
/**
* The expected references to be resolved.
*/
[
new ExpectedReference(
rootUri,
createTestFileReference('folder1/file3.prompt.md', 7, 14),
createFileReference('folder1/file3.prompt.md', 7, 14),
),
new ExpectedReference(
rootUri,
new MarkdownLink(
createMarkdownReference(
8, 14,
'[file4.prompt.md]', '(./folder1/some-other-folder/file4.prompt.md)',
),
@@ -640,9 +608,7 @@ suite('PromptFileReference', function () {
]
));
const rootReference = await test.run();
const { metadata } = rootReference;
const metadata = await test.run();
assert.deepStrictEqual(
metadata,
@@ -650,6 +616,7 @@ suite('PromptFileReference', function () {
promptType: PromptsType.instructions,
applyTo: '**/*',
description: 'Description of my instructions file.',
tools: ['my-tool12'],
},
'Must have correct metadata.',
);
@@ -657,10 +624,10 @@ suite('PromptFileReference', function () {
});
suite('tools and mode compatibility', () => {
test('tools are ignored if root prompt is in the ask mode', async function () {
test('ask mode', async function () {
const rootFolderName = 'resolves-nested-file-references';
const rootFolder = `/${rootFolderName}`;
const rootUri = URI.file(rootFolder);
const rootUri = toUri(rootFolder);
const test = testDisposables.add(instantiationService.createInstance(TestPromptFileReference,
/**
@@ -728,18 +695,18 @@ suite('PromptFileReference', function () {
/**
* The root file path to start the resolve process from.
*/
URI.file(`/${rootFolderName}/file2.prompt.md`),
toUri(`/${rootFolderName}/file2.prompt.md`),
/**
* The expected references to be resolved.
*/
[
new ExpectedReference(
rootUri,
createTestFileReference('folder1/file3.prompt.md', 6, 14),
createFileReference('folder1/file3.prompt.md', 6, 14),
),
new ExpectedReference(
rootUri,
new MarkdownLink(
createMarkdownReference(
7, 14,
'[file4.prompt.md]', '(./folder1/some-other-folder/file4.prompt.md)',
),
@@ -747,9 +714,7 @@ suite('PromptFileReference', function () {
]
));
const rootReference = await test.run();
const { metadata } = rootReference;
const metadata = await test.run();
assert.deepStrictEqual(
metadata,
@@ -762,10 +727,10 @@ suite('PromptFileReference', function () {
);
});
test('tools are ignored if root prompt is in the edit mode', async function () {
test('edit mode', async function () {
const rootFolderName = 'resolves-nested-file-references';
const rootFolder = `/${rootFolderName}`;
const rootUri = URI.file(rootFolder);
const rootUri = toUri(rootFolder);
const test = testDisposables.add(instantiationService.createInstance(TestPromptFileReference,
/**
@@ -832,18 +797,18 @@ suite('PromptFileReference', function () {
/**
* The root file path to start the resolve process from.
*/
URI.file(`/${rootFolderName}/file2.prompt.md`),
toUri(`/${rootFolderName}/file2.prompt.md`),
/**
* The expected references to be resolved.
*/
[
new ExpectedReference(
rootUri,
createTestFileReference('folder1/file3.prompt.md', 6, 14),
createFileReference('folder1/file3.prompt.md', 6, 14),
),
new ExpectedReference(
rootUri,
new MarkdownLink(
createMarkdownReference(
7, 14,
'[file4.prompt.md]', '(./folder1/some-other-folder/file4.prompt.md)',
),
@@ -851,9 +816,7 @@ suite('PromptFileReference', function () {
]
));
const rootReference = await test.run();
const { metadata } = rootReference;
const metadata = await test.run();
assert.deepStrictEqual(
metadata,
@@ -867,10 +830,10 @@ suite('PromptFileReference', function () {
});
test('tools are not ignored if root prompt is in the agent mode', async function () {
test('agent mode', async function () {
const rootFolderName = 'resolves-nested-file-references';
const rootFolder = `/${rootFolderName}`;
const rootUri = URI.file(rootFolder);
const rootUri = toUri(rootFolder);
const test = testDisposables.add(instantiationService.createInstance(TestPromptFileReference,
/**
@@ -937,18 +900,18 @@ suite('PromptFileReference', function () {
/**
* The root file path to start the resolve process from.
*/
URI.file(`/${rootFolderName}/file2.prompt.md`),
toUri(`/${rootFolderName}/file2.prompt.md`),
/**
* The expected references to be resolved.
*/
[
new ExpectedReference(
rootUri,
createTestFileReference('folder1/file3.prompt.md', 6, 14),
createFileReference('folder1/file3.prompt.md', 6, 14),
),
new ExpectedReference(
rootUri,
new MarkdownLink(
createMarkdownReference(
7, 14,
'[file4.prompt.md]', '(./folder1/some-other-folder/file4.prompt.md)',
),
@@ -956,9 +919,7 @@ suite('PromptFileReference', function () {
]
));
const rootReference = await test.run();
const { metadata } = rootReference;
const metadata = await test.run();
assert.deepStrictEqual(
metadata,
@@ -972,10 +933,10 @@ suite('PromptFileReference', function () {
});
test('tools are not ignored if root prompt implicitly in the agent mode', async function () {
test('no mode', async function () {
const rootFolderName = 'resolves-nested-file-references';
const rootFolder = `/${rootFolderName}`;
const rootUri = URI.file(rootFolder);
const rootUri = toUri(rootFolder);
const test = testDisposables.add(instantiationService.createInstance(TestPromptFileReference,
/**
@@ -1042,18 +1003,18 @@ suite('PromptFileReference', function () {
/**
* The root file path to start the resolve process from.
*/
URI.file(`/${rootFolderName}/file2.prompt.md`),
toUri(`/${rootFolderName}/file2.prompt.md`),
/**
* The expected references to be resolved.
*/
[
new ExpectedReference(
rootUri,
createTestFileReference('folder1/file3.prompt.md', 6, 14),
createFileReference('folder1/file3.prompt.md', 6, 14),
),
new ExpectedReference(
rootUri,
new MarkdownLink(
createMarkdownReference(
7, 14,
'[file4.prompt.md]', '(./folder1/some-other-folder/file4.prompt.md)',
),
@@ -1061,15 +1022,12 @@ suite('PromptFileReference', function () {
]
));
const rootReference = await test.run();
const { metadata, } = rootReference;
const metadata = await test.run();
assert.deepStrictEqual(
metadata,
{
promptType: PromptsType.prompt,
mode: ChatModeKind.Agent,
tools: ['my-tool12'],
description: 'Description of the prompt file.',
},

View File

@@ -0,0 +1,203 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js';
import { Range } from '../../../../../../../editor/common/core/range.js';
import { URI } from '../../../../../../../base/common/uri.js';
import { NewPromptsParser } from '../../../../common/promptSyntax/service/newPromptsParser.js';
suite('NewPromptsParser', () => {
ensureNoDisposablesAreLeakedInTestSuite();
test('mode', async () => {
const uri = URI.parse('file:///test/chatmode.md');
const content = [
/* 01 */"---",
/* 02 */`description: "Agent mode test"`,
/* 03 */"model: GPT 4.1",
/* 04 */"tools: ['tool1', 'tool2']",
/* 05 */"---",
/* 06 */"This is a chat mode test.",
/* 07 */"Here is a #tool1 variable and a #file:./reference1.md as well as a [reference](./reference2.md).",
].join('\n');
const result = new NewPromptsParser().parse(uri, content);
assert.deepEqual(result.uri, uri);
assert.ok(result.header);
assert.ok(result.body);
assert.deepEqual(result.header.range, { startLineNumber: 2, startColumn: 1, endLineNumber: 5, endColumn: 1 });
assert.deepEqual(result.header.attributes, [
{ key: 'description', range: new Range(2, 1, 2, 31), value: { type: 'string', value: 'Agent mode test', range: new Range(2, 14, 2, 31) } },
{ key: 'model', range: new Range(3, 1, 3, 15), value: { type: 'string', value: 'GPT 4.1', range: new Range(3, 8, 3, 15) } },
{
key: 'tools', range: new Range(4, 1, 4, 26), value: {
type: 'array',
items: [{ type: 'string', value: 'tool1', range: new Range(4, 9, 4, 16) }, { type: 'string', value: 'tool2', range: new Range(4, 18, 4, 25) }],
range: new Range(4, 8, 4, 26)
}
},
]);
assert.deepEqual(result.body.range, { startLineNumber: 6, startColumn: 1, endLineNumber: 8, endColumn: 1 });
assert.equal(result.body.offset, 80);
assert.equal(result.body.getContent(), 'This is a chat mode test.\nHere is a #tool1 variable and a #file:./reference1.md as well as a [reference](./reference2.md).');
assert.deepEqual(result.body.fileReferences, [
{ range: new Range(7, 39, 7, 54), content: './reference1.md', isMarkdownLink: false },
{ range: new Range(7, 80, 7, 95), content: './reference2.md', isMarkdownLink: true }
]);
assert.deepEqual(result.body.variableReferences, [
{ range: new Range(7, 12, 7, 17), name: 'tool1', offset: 116 }
]);
assert.deepEqual(result.header.description, 'Agent mode test');
assert.deepEqual(result.header.model, 'GPT 4.1');
assert.ok(result.header.tools);
assert.deepEqual(result.header.tools, ['tool1', 'tool2']);
});
test('instructions', async () => {
const uri = URI.parse('file:///test/prompt1.md');
const content = [
/* 01 */"---",
/* 02 */`description: "Code style instructions for TypeScript"`,
/* 03 */"applyTo: *.ts",
/* 04 */"---",
/* 05 */"Follow my companies coding guidlines at [mycomp-ts-guidelines](https://mycomp/guidelines#typescript.md)",
].join('\n');
const result = new NewPromptsParser().parse(uri, content);
assert.deepEqual(result.uri, uri);
assert.ok(result.header);
assert.ok(result.body);
assert.deepEqual(result.header.range, { startLineNumber: 2, startColumn: 1, endLineNumber: 4, endColumn: 1 });
assert.deepEqual(result.header.attributes, [
{ key: 'description', range: new Range(2, 1, 2, 54), value: { type: 'string', value: 'Code style instructions for TypeScript', range: new Range(2, 14, 2, 54) } },
{ key: 'applyTo', range: new Range(3, 1, 3, 14), value: { type: 'string', value: '*.ts', range: new Range(3, 10, 3, 14) } },
]);
assert.deepEqual(result.body.range, { startLineNumber: 5, startColumn: 1, endLineNumber: 6, endColumn: 1 });
assert.equal(result.body.offset, 76);
assert.equal(result.body.getContent(), 'Follow my companies coding guidlines at [mycomp-ts-guidelines](https://mycomp/guidelines#typescript.md)');
assert.deepEqual(result.body.fileReferences, [
{ range: new Range(5, 64, 5, 103), content: 'https://mycomp/guidelines#typescript.md', isMarkdownLink: true },
]);
assert.deepEqual(result.body.variableReferences, []);
assert.deepEqual(result.header.description, 'Code style instructions for TypeScript');
assert.deepEqual(result.header.applyTo, '*.ts');
});
test('prompt file', async () => {
const uri = URI.parse('file:///test/prompt2.md');
const content = [
/* 01 */"---",
/* 02 */`description: "General purpose coding assistant"`,
/* 03 */"mode: agent",
/* 04 */"model: GPT 4.1",
/* 05 */"tools: ['search', 'terminal']",
/* 06 */"---",
/* 07 */"This is a prompt file body referencing #search and [docs](https://example.com/docs).",
].join('\n');
const result = new NewPromptsParser().parse(uri, content);
assert.deepEqual(result.uri, uri);
assert.ok(result.header);
assert.ok(result.body);
assert.deepEqual(result.header.range, { startLineNumber: 2, startColumn: 1, endLineNumber: 6, endColumn: 1 });
assert.deepEqual(result.header.attributes, [
{ key: 'description', range: new Range(2, 1, 2, 48), value: { type: 'string', value: 'General purpose coding assistant', range: new Range(2, 14, 2, 48) } },
{ key: 'mode', range: new Range(3, 1, 3, 12), value: { type: 'string', value: 'agent', range: new Range(3, 7, 3, 12) } },
{ key: 'model', range: new Range(4, 1, 4, 15), value: { type: 'string', value: 'GPT 4.1', range: new Range(4, 8, 4, 15) } },
{
key: 'tools', range: new Range(5, 1, 5, 30), value: {
type: 'array',
items: [{ type: 'string', value: 'search', range: new Range(5, 9, 5, 17) }, { type: 'string', value: 'terminal', range: new Range(5, 19, 5, 29) }],
range: new Range(5, 8, 5, 30)
}
},
]);
assert.deepEqual(result.body.range, { startLineNumber: 7, startColumn: 1, endLineNumber: 8, endColumn: 1 });
assert.equal(result.body.offset, 113);
assert.equal(result.body.getContent(), 'This is a prompt file body referencing #search and [docs](https://example.com/docs).');
assert.deepEqual(result.body.fileReferences, [
{ range: new Range(7, 59, 7, 83), content: 'https://example.com/docs', isMarkdownLink: true },
]);
assert.deepEqual(result.body.variableReferences, [
{ range: new Range(7, 41, 7, 47), name: 'search', offset: 152 }
]);
assert.deepEqual(result.header.description, 'General purpose coding assistant');
assert.deepEqual(result.header.mode, 'agent');
assert.deepEqual(result.header.model, 'GPT 4.1');
assert.ok(result.header.tools);
assert.deepEqual(result.header.tools, ['search', 'terminal']);
});
test('prompt file tools as map', async () => {
const uri = URI.parse('file:///test/prompt2.md');
const content = [
/* 01 */"---",
/* 02 */"tools:",
/* 03 */" built-in: true",
/* 04 */" mcp:",
/* 05 */" vscode-playright-mcp:",
/* 06 */" browser-click: true",
/* 07 */" extensions:",
/* 08 */" github.vscode-pull-request-github:",
/* 09 */" openPullRequest: true",
/* 10 */" copilotCodingAgent: false",
/* 11 */"---",
].join('\n');
const result = new NewPromptsParser().parse(uri, content);
assert.deepEqual(result.uri, uri);
assert.ok(result.header);
assert.ok(!result.body);
assert.deepEqual(result.header.range, { startLineNumber: 2, startColumn: 1, endLineNumber: 11, endColumn: 1 });
assert.deepEqual(result.header.attributes, [
{
key: 'tools', range: new Range(2, 1, 10, 32), value: {
type: 'object',
properties: [
{
"key": { type: 'string', value: 'built-in', range: new Range(3, 3, 3, 11) },
"value": { type: 'boolean', value: true, range: new Range(3, 13, 3, 17) }
},
{
"key": { type: 'string', value: 'mcp', range: new Range(4, 3, 4, 6) },
"value": {
type: 'object', range: new Range(5, 5, 6, 26), properties: [
{
"key": { type: 'string', value: 'vscode-playright-mcp', range: new Range(5, 5, 5, 25) }, "value": {
type: 'object', range: new Range(6, 7, 6, 26), properties: [
{ "key": { type: 'string', value: 'browser-click', range: new Range(6, 7, 6, 20) }, "value": { type: 'boolean', value: true, range: new Range(6, 22, 6, 26) } }
]
}
}
]
}
},
{
"key": { type: 'string', value: 'extensions', range: new Range(7, 3, 7, 13) },
"value": {
type: 'object', range: new Range(8, 5, 10, 32), properties: [
{
"key": { type: 'string', value: 'github.vscode-pull-request-github', range: new Range(8, 5, 8, 38) }, "value": {
type: 'object', range: new Range(9, 7, 10, 32), properties: [
{ "key": { type: 'string', value: 'openPullRequest', range: new Range(9, 7, 9, 22) }, "value": { type: 'boolean', value: true, range: new Range(9, 24, 9, 28) } },
{ "key": { type: 'string', value: 'copilotCodingAgent', range: new Range(10, 7, 10, 25) }, "value": { type: 'boolean', value: false, range: new Range(10, 27, 10, 32) } }
]
}
}
]
}
},
],
range: new Range(3, 3, 10, 32)
},
}
]);
assert.deepEqual(result.header.description, undefined);
assert.deepEqual(result.header.mode, undefined);
assert.deepEqual(result.header.model, undefined);
assert.ok(result.header.tools);
assert.deepEqual(result.header.tools, ['built-in', 'browser-click', 'openPullRequest', 'copilotCodingAgent']);
});
});

View File

@@ -0,0 +1,329 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js';
import { URI } from '../../../../../../../base/common/uri.js';
import { NewPromptsParser } from '../../../../common/promptSyntax/service/newPromptsParser.js';
import { PromptValidator } from '../../../../common/promptSyntax/service/promptValidator.js';
import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js';
import { PromptsConfig } from '../../../../common/promptSyntax/config/config.js';
import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js';
import { ILanguageModelToolsService, IToolData, ToolDataSource, ToolSet } from '../../../../common/languageModelToolsService.js';
import { ObservableSet } from '../../../../../../../base/common/observable.js';
import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../../common/languageModels.js';
import { ExtensionIdentifier } from '../../../../../../../platform/extensions/common/extensions.js';
import { ChatMode, CustomChatMode, IChatModeService } from '../../../../common/chatModes.js';
import { MockChatModeService } from '../../mockChatModeService.js';
import { PromptsType } from '../../../../common/promptSyntax/promptTypes.js';
import { IMarkerData, MarkerSeverity } from '../../../../../../../platform/markers/common/markers.js';
import { getPromptFileExtension } from '../../../../common/promptSyntax/config/promptFileLocations.js';
import { IFileService } from '../../../../../../../platform/files/common/files.js';
import { ResourceSet } from '../../../../../../../base/common/map.js';
suite('PromptValidator', () => {
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
let instaService: TestInstantiationService;
setup(async () => {
instaService = disposables.add(new TestInstantiationService());
const testConfigService = new TestConfigurationService();
testConfigService.setUserConfiguration(PromptsConfig.KEY, true);
instaService.stub(IConfigurationService, testConfigService);
const testTool1 = { id: 'testTool1', displayName: 'tool1', canBeReferencedInPrompt: true, modelDescription: 'Test Tool 1', source: ToolDataSource.External, inputSchema: {} } satisfies IToolData;
const testTool2 = { id: 'testTool2', displayName: 'tool2', canBeReferencedInPrompt: true, toolReferenceName: 'tool2', modelDescription: 'Test Tool 2', source: ToolDataSource.External, inputSchema: {} } satisfies IToolData;
instaService.stub(ILanguageModelToolsService, {
getTools() { return [testTool1, testTool2]; },
toolSets: new ObservableSet<ToolSet>().observable
});
const testModels: ILanguageModelChatMetadata[] = [
{ id: 'mae-4', name: 'MAE 4', vendor: 'olama', version: '1.0', family: 'mae', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, capabilities: { agentMode: true, toolCalling: true } } satisfies ILanguageModelChatMetadata,
{ id: 'mae-4.1', name: 'MAE 4.1', vendor: 'copilot', version: '1.0', family: 'mae', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, capabilities: { agentMode: true, toolCalling: true } } satisfies ILanguageModelChatMetadata,
{ id: 'mae-3.5-turbo', name: 'MAE 3.5 Turbo', vendor: 'copilot', version: '1.0', family: 'mae', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024 } satisfies ILanguageModelChatMetadata
];
instaService.stub(ILanguageModelsService, {
getLanguageModelIds() { return testModels.map(m => m.id); },
lookupLanguageModel(name: string) {
return testModels.find(m => m.id === name);
}
});
const customChatMode = new CustomChatMode({ uri: URI.parse('myFs://test/test/chatmode.md'), name: 'BeastMode', body: '', variableReferences: [] });
instaService.stub(IChatModeService, new MockChatModeService({ builtin: [ChatMode.Agent, ChatMode.Ask, ChatMode.Edit], custom: [customChatMode] }));
const existingFiles = new ResourceSet([URI.parse('myFs://test/reference1.md'), URI.parse('myFs://test/reference2.md')]);
instaService.stub(IFileService, {
exists(uri: URI) {
return Promise.resolve(existingFiles.has(uri));
}
});
});
async function validate(code: string, promptType: PromptsType): Promise<IMarkerData[]> {
const uri = URI.parse('myFs://test/testFile' + getPromptFileExtension(promptType));
const result = new NewPromptsParser().parse(uri, code);
const validator = instaService.createInstance(PromptValidator);
const markers: IMarkerData[] = [];
await validator.validate(result, promptType, m => markers.push(m));
return markers;
}
suite('modes', () => {
test('correct mode', async () => {
const content = [
/* 01 */"---",
/* 02 */`description: "Agent mode test"`,
/* 03 */"model: MAE 4.1",
/* 04 */"tools: ['tool1', 'tool2']",
/* 05 */"---",
/* 06 */"This is a chat mode test.",
/* 07 */"Here is a #tool1 variable and a #file:./reference1.md as well as a [reference](./reference2.md).",
].join('\n');
const markers = await validate(content, PromptsType.mode);
assert.deepStrictEqual(markers, []);
});
test('mode with errors (empty description, unknown tool & model)', async () => {
const content = [
/* 01 */"---",
/* 02 */`description: ""`, // empty description -> error
/* 03 */"model: MAE 4.2", // unknown model -> warning
/* 04 */"tools: ['tool1', 'tool2', 'tool3']", // tool3 unknown -> warning
/* 05 */"---",
/* 06 */"Body",
].join('\n');
const markers = await validate(content, PromptsType.mode);
assert.strictEqual(markers.length, 3, 'Expected 3 validation issues');
assert.deepStrictEqual(
markers.map(m => ({ severity: m.severity, message: m.message })),
[
{ severity: MarkerSeverity.Error, message: "The 'description' attribute should not be empty." },
{ severity: MarkerSeverity.Warning, message: "Unknown tool 'tool3'." },
{ severity: MarkerSeverity.Warning, message: "Unknown model 'MAE 4.2'." },
]
);
});
test('tools must be array', async () => {
const content = [
"---",
"description: \"Test\"",
"tools: 'tool1'",
"---",
].join('\n');
const markers = await validate(content, PromptsType.mode);
assert.strictEqual(markers.length, 1);
assert.deepStrictEqual(markers.map(m => m.message), ["The 'tools' attribute must be an array."]);
});
test('each tool must be string', async () => {
const content = [
"---",
"description: \"Test\"",
"tools: ['tool1', 2]",
"---",
].join('\n');
const markers = await validate(content, PromptsType.mode);
assert.strictEqual(markers.length, 1);
assert.strictEqual(markers[0].message, "Each tool name in the 'tools' attribute must be a string.");
});
test('unknown attribute in mode file', async () => {
const content = [
"---",
"description: \"Test\"",
"applyTo: '*.ts'", // not allowed in mode file
"---",
].join('\n');
const markers = await validate(content, PromptsType.mode);
assert.strictEqual(markers.length, 1);
assert.strictEqual(markers[0].severity, MarkerSeverity.Warning);
assert.ok(markers[0].message.startsWith("Attribute 'applyTo' is not supported in mode files."));
});
});
suite('instructions', () => {
test('instructions valid', async () => {
const content = [
"---",
"description: \"Instr\"",
"applyTo: *.ts,*.js",
"---",
].join('\n');
const markers = await validate(content, PromptsType.instructions);
assert.deepEqual(markers, []);
});
test('instructions invalid applyTo type', async () => {
const content = [
"---",
"description: \"Instr\"",
"applyTo: 5",
"---",
].join('\n');
const markers = await validate(content, PromptsType.instructions);
assert.strictEqual(markers.length, 1);
assert.strictEqual(markers[0].message, "The 'applyTo' attribute must be a string.");
});
test('instructions invalid applyTo glob & unknown attribute', async () => {
const content = [
"---",
"description: \"Instr\"",
"applyTo: ''", // empty -> invalid glob
"model: mae-4", // model not allowed in instructions
"---",
].join('\n');
const markers = await validate(content, PromptsType.instructions);
assert.strictEqual(markers.length, 2);
// Order: unknown attribute warnings first (attribute iteration) then applyTo validation
assert.strictEqual(markers[0].severity, MarkerSeverity.Warning);
assert.ok(markers[0].message.startsWith("Attribute 'model' is not supported in instructions files."));
assert.strictEqual(markers[1].message, "The 'applyTo' attribute must be a valid glob pattern.");
});
test('invalid header structure (YAML array)', async () => {
const content = [
"---",
"- item1",
"---",
"Body",
].join('\n');
const markers = await validate(content, PromptsType.instructions);
assert.strictEqual(markers.length, 1);
assert.strictEqual(markers[0].message, 'Invalid header, expecting <key: value> pairs');
});
});
suite('prompts', () => {
test('prompt valid with agent mode (default) and tools and a BYO model', async () => {
// mode omitted -> defaults to Agent; tools+model should validate; model MAE 4 is agent capable
const content = [
'---',
'description: "Prompt with tools"',
"model: MAE 4.1",
"tools: ['tool1','tool2']",
'---',
'Body'
].join('\n');
const markers = await validate(content, PromptsType.prompt);
assert.deepStrictEqual(markers, []);
});
test('prompt model not suited for agent mode', async () => {
// MAE 3.5 Turbo lacks agentMode capability -> warning when used in agent (default) mode
const content = [
'---',
'description: "Prompt with unsuitable model"',
"model: MAE 3.5 Turbo",
'---',
'Body'
].join('\n');
const markers = await validate(content, PromptsType.prompt);
assert.strictEqual(markers.length, 1, 'Expected one warning about unsuitable model');
assert.strictEqual(markers[0].severity, MarkerSeverity.Warning);
assert.strictEqual(markers[0].message, "Model 'MAE 3.5 Turbo' is not suited for agent mode.");
});
test('prompt with custom mode BeastMode and tools', async () => {
// Explicit custom mode should be recognized; BeastMode kind comes from setup; ensure tools accepted
const content = [
'---',
'description: "Prompt custom mode"',
'mode: BeastMode',
"tools: ['tool1']",
'---',
'Body'
].join('\n');
const markers = await validate(content, PromptsType.prompt);
assert.deepStrictEqual(markers, []);
});
test('prompt with unknown mode Ask', async () => {
const content = [
'---',
'description: "Prompt unknown mode Ask"',
'mode: Ask',
"tools: ['tool1','tool2']",
'---',
'Body'
].join('\n');
const markers = await validate(content, PromptsType.prompt);
assert.strictEqual(markers.length, 1, 'Expected one warning about tools in non-agent mode');
assert.strictEqual(markers[0].severity, MarkerSeverity.Warning);
assert.strictEqual(markers[0].message, "Unknown mode 'Ask'. Available modes: agent, ask, edit, BeastMode.");
});
test('prompt with mode edit', async () => {
const content = [
'---',
'description: "Prompt edit mode with tool"',
'mode: edit',
"tools: ['tool1']",
'---',
'Body'
].join('\n');
const markers = await validate(content, PromptsType.prompt);
assert.strictEqual(markers.length, 1);
assert.strictEqual(markers[0].severity, MarkerSeverity.Warning);
assert.strictEqual(markers[0].message, "The 'tools' attribute is only supported in agent mode. Attribute will be ignored.");
});
});
suite('body', () => {
test('body with existing file references and known tools has no markers', async () => {
const content = [
'---',
'description: "Refs"',
'---',
'Here is a #file:./reference1.md and a markdown [reference](./reference2.md) plus variables #tool1 and #tool2'
].join('\n');
const markers = await validate(content, PromptsType.prompt);
assert.deepStrictEqual(markers, [], 'Expected no validation issues');
});
test('body with missing file references reports warnings', async () => {
const content = [
'---',
'description: "Missing Refs"',
'---',
'Here is a #file:./missing1.md and a markdown [missing link](./missing2.md).'
].join('\n');
const markers = await validate(content, PromptsType.prompt);
const messages = markers.map(m => m.message).sort();
assert.deepStrictEqual(messages, [
"File './missing1.md' not found.",
"File './missing2.md' not found."
]);
});
test('body with unknown tool variable reference warns', async () => {
const content = [
'---',
'description: "Unknown tool var"',
'---',
'This line references known #tool1 and unknown #toolX'
].join('\n');
const markers = await validate(content, PromptsType.prompt);
assert.strictEqual(markers.length, 1, 'Expected one warning for unknown tool variable');
assert.strictEqual(markers[0].severity, MarkerSeverity.Warning);
assert.strictEqual(markers[0].message, "Unknown tool or toolset 'toolX'.");
});
});
});

View File

@@ -27,7 +27,7 @@ import { INSTRUCTIONS_LANGUAGE_ID, PROMPT_LANGUAGE_ID, PromptsType } from '../..
import { TextModelPromptParser } from '../../../../common/promptSyntax/parsers/textModelPromptParser.js';
import { IPromptFileReference } from '../../../../common/promptSyntax/parsers/types.js';
import { PromptsService } from '../../../../common/promptSyntax/service/promptsServiceImpl.js';
import { ICustomChatMode, IPromptParserResult, IPromptsService } from '../../../../common/promptSyntax/service/promptsService.js';
import { ICustomChatMode, IPromptsService } from '../../../../common/promptSyntax/service/promptsService.js';
import { MockFilesystem } from '../testUtils/mockFilesystem.js';
import { ILabelService } from '../../../../../../../platform/label/common/label.js';
import { ComputeAutomaticInstructions, newInstructionsCollectionEvent } from '../../../../common/promptSyntax/computeAutomaticInstructions.js';
@@ -42,6 +42,7 @@ import { testWorkspace } from '../../../../../../../platform/workspace/test/comm
import { IUserDataProfileService } from '../../../../../../services/userDataProfile/common/userDataProfile.js';
import { ITelemetryService } from '../../../../../../../platform/telemetry/common/telemetry.js';
import { NullTelemetryService } from '../../../../../../../platform/telemetry/common/telemetryUtils.js';
import { Event } from '../../../../../../../base/common/event.js';
/**
* Helper class to assert the properties of a link.
@@ -139,7 +140,7 @@ suite('PromptsService', () => {
const fileService = disposables.add(instaService.createInstance(FileService));
instaService.stub(IFileService, fileService);
instaService.stub(IModelService, { getModel() { return null; } });
instaService.stub(IModelService, { getModel() { return null; }, onModelRemoved: Event.None });
instaService.stub(ILanguageService, {
guessLanguageIdByFilepathOrFirstLine(uri: URI) {
if (uri.path.endsWith(PROMPT_FILE_EXTENSION)) {
@@ -696,74 +697,57 @@ suite('PromptsService', () => {
const yetAnotherFile = URI.joinPath(rootFolderUri, 'folder1/some-other-folder/yetAnotherFolder🤭/another-file.instructions.md');
const result1 = await service.parse(rootFileUri, PromptsType.prompt, CancellationToken.None);
assert.deepEqual(result1, {
uri: rootFileUri,
metadata: {
promptType: PromptsType.prompt,
description: 'Root prompt description.',
tools: ['my-tool1'],
mode: 'agent',
},
topError: undefined,
fileReferences: [file3, file4],
variableReferences: [
{
name: "my-other-tool",
range: {
endExclusive: 265,
start: 251
}
},
{
name: "my-tool",
range: {
start: 239,
endExclusive: 247,
}
}]
} satisfies IPromptParserResult);
const result1 = await service.parseNew(rootFileUri, CancellationToken.None);
assert.deepEqual(result1.uri, rootFileUri);
assert.deepEqual(result1.header?.description, 'Root prompt description.');
assert.deepEqual(result1.header?.tools, ['my-tool1']);
assert.deepEqual(result1.header?.mode, 'agent');
assert.ok(result1.body);
assert.deepEqual(
result1.body.fileReferences.map(r => result1.body?.resolveFilePath(r.content)),
[file3, file4],
);
assert.deepEqual(
result1.body.variableReferences,
[
{ name: "my-tool", range: new Range(10, 5, 10, 12), offset: 239 },
{ name: "my-other-tool", range: new Range(11, 5, 11, 18), offset: 251 },
]
);
const result2 = await service.parse(file3, PromptsType.prompt, CancellationToken.None);
assert.deepEqual(result2, {
uri: file3,
metadata: {
promptType: PromptsType.prompt,
mode: 'edit',
},
topError: undefined,
fileReferences: [nonExistingFolder, yetAnotherFile],
variableReferences: []
} satisfies IPromptParserResult);
const result2 = await service.parseNew(file3, CancellationToken.None);
assert.deepEqual(result2.uri, file3);
assert.deepEqual(result2.header?.mode, 'edit');
assert.ok(result2.body);
assert.deepEqual(
result2.body.fileReferences.map(r => result2.body?.resolveFilePath(r.content)),
[nonExistingFolder, yetAnotherFile],
);
const result3 = await service.parse(yetAnotherFile, PromptsType.instructions, CancellationToken.None);
assert.deepEqual(result3, {
uri: yetAnotherFile,
metadata: {
promptType: PromptsType.instructions,
description: 'Another file description.',
applyTo: '**/*.tsx',
},
topError: undefined,
fileReferences: [someOtherFolder, someOtherFolderFile],
variableReferences: []
} satisfies IPromptParserResult);
const result3 = await service.parseNew(yetAnotherFile, CancellationToken.None);
assert.deepEqual(result3.uri, yetAnotherFile);
assert.deepEqual(result3.header?.description, 'Another file description.');
assert.deepEqual(result3.header?.applyTo, '**/*.tsx');
assert.ok(result3.body);
assert.deepEqual(
result3.body.fileReferences.map(r => result3.body?.resolveFilePath(r.content)),
[someOtherFolder, someOtherFolderFile],
);
assert.deepEqual(result3.body.variableReferences, []);
const result4 = await service.parse(file4, PromptsType.instructions, CancellationToken.None);
assert.deepEqual(result4, {
uri: file4,
metadata: {
promptType: PromptsType.instructions,
description: 'File 4 splendid description.',
},
topError: undefined,
fileReferences: [
const result4 = await service.parseNew(file4, CancellationToken.None);
assert.deepEqual(result4.uri, file4);
assert.deepEqual(result4.header?.description, 'File 4 splendid description.');
assert.ok(result4.body);
assert.deepEqual(
result4.body.fileReferences.map(r => result4.body?.resolveFilePath(r.content)),
[
URI.joinPath(rootFolderUri, '/folder1/some-other-folder/some-non-existing/file.prompt.md'),
URI.joinPath(rootFolderUri, '/folder1/some-other-folder/some-non-prompt-file.md'),
URI.joinPath(rootFolderUri, '/folder1/'),
],
variableReferences: []
} satisfies IPromptParserResult);
);
assert.deepEqual(result4.body.variableReferences, []);
});
});
@@ -1291,18 +1275,15 @@ suite('PromptsService', () => {
},
{
name: 'mode2',
description: undefined,
tools: undefined,
body: 'First use #tool2\nThen use #tool1',
variableReferences: [{ name: 'tool1', range: { start: 26, endExclusive: 32 } }, { name: 'tool2', range: { start: 10, endExclusive: 16 } }],
model: undefined,
uri: URI.joinPath(rootFolderUri, '.github/chatmodes/mode2.instructions.md'),
}
];
assert.deepEqual(
expected,
result,
expected,
'Must get custom chat modes.',
);
});

View File

@@ -47,11 +47,11 @@ export class MockFilesystem {
/**
* Starts the mock process.
*/
public async mock(): Promise<TWithURI<IMockFolder>[]> {
public async mock(parentFolder?: URI): Promise<TWithURI<IMockFolder>[]> {
const result = await Promise.all(
this.folders
.map((folder) => {
return this.mockFolder(folder);
return this.mockFolder(folder, parentFolder);
}),
);