diff --git a/src/vs/workbench/contrib/chat/common/chatModes.ts b/src/vs/workbench/contrib/chat/common/chatModes.ts index aa345b2874b..58470cb29d5 100644 --- a/src/vs/workbench/contrib/chat/common/chatModes.ts +++ b/src/vs/workbench/contrib/chat/common/chatModes.ts @@ -17,6 +17,7 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { IChatAgentService } from './chatAgents.js'; import { ChatContextKeys } from './chatContextKeys.js'; import { ChatModeKind } from './constants.js'; +import { IHandOff } from './promptSyntax/service/newPromptsParser.js'; import { ICustomChatMode, IPromptsService } from './promptSyntax/service/promptsService.js'; export const IChatModeService = createDecorator('chatModeService'); @@ -99,6 +100,7 @@ export class ChatModeService extends Disposable implements IChatModeService { tools: cachedMode.customTools, model: cachedMode.model, modeInstructions: cachedMode.modeInstructions ?? { content: cachedMode.body ?? '', toolReferences: [] }, + handOffs: cachedMode.handOffs }; const instance = new CustomChatMode(customChatMode); this._customModeInstances.set(uri.toString(), instance); @@ -202,6 +204,7 @@ export interface IChatModeData { readonly model?: string; readonly modeInstructions?: IChatModeInstructions; readonly body?: string; /* deprecated */ + readonly handOffs?: readonly IHandOff[]; readonly uri?: URI; } @@ -213,6 +216,7 @@ export interface IChatMode { readonly isBuiltin: boolean; readonly kind: ChatModeKind; readonly customTools?: IObservable; + readonly handOffs?: IObservable; readonly model?: IObservable; readonly modeInstructions?: IObservable; readonly uri?: IObservable; @@ -242,6 +246,7 @@ function isCachedChatModeData(data: unknown): data is IChatModeData { (mode.customTools === undefined || Array.isArray(mode.customTools)) && (mode.modeInstructions === undefined || (typeof mode.modeInstructions === 'object' && mode.modeInstructions !== null)) && (mode.model === undefined || typeof mode.model === 'string') && + (mode.handOffs === undefined || Array.isArray(mode.handOffs)) && (mode.uri === undefined || (typeof mode.uri === 'object' && mode.uri !== null)); } @@ -251,6 +256,7 @@ export class CustomChatMode implements IChatMode { private readonly _modeInstructions: ISettableObservable; private readonly _uriObservable: ISettableObservable; private readonly _modelObservable: ISettableObservable; + private readonly _handoffsObservable: ISettableObservable; public readonly id: string; public readonly name: string; @@ -283,6 +289,10 @@ export class CustomChatMode implements IChatMode { return this.name; } + get handOffs(): IObservable { + return this._handoffsObservable; + } + public readonly kind = ChatModeKind.Agent; constructor( @@ -295,6 +305,7 @@ export class CustomChatMode implements IChatMode { this._modelObservable = observableValue('model', customChatMode.model); this._modeInstructions = observableValue('_modeInstructions', customChatMode.modeInstructions); this._uriObservable = observableValue('uri', customChatMode.uri); + this._handoffsObservable = observableValue('handoffs', customChatMode.handOffs); } /** @@ -320,7 +331,8 @@ export class CustomChatMode implements IChatMode { customTools: this.customTools.get(), model: this.model.get(), modeInstructions: this.modeInstructions.get(), - uri: this.uri.get() + uri: this.uri.get(), + handOffs: this.handOffs.get() }; } } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts index ca13e60ddf1..d84a8ac728d 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts @@ -17,6 +17,7 @@ import { IPromptsService } from '../service/promptsService.js'; import { Iterable } from '../../../../../../base/common/iterator.js'; import { PromptHeader } from '../service/newPromptsParser.js'; import { getValidAttributeNames } from './promptValidator.js'; +import { localize } from '../../../../../../nls.js'; export class PromptHeaderAutocompletion implements CompletionItemProvider { /** @@ -140,7 +141,6 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { } } - const bracketIndex = lineContent.indexOf('['); if (bracketIndex !== -1 && bracketIndex <= position.column - 1) { // if the property is already inside a bracket, we don't provide value completions @@ -158,6 +158,22 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { }; suggestions.push(item); } + if (property === 'handoffs' && (promptType === PromptsType.mode)) { + const value = [ + '', + ' - label: Start Implementation', + ' agent: agent', + ' prompt: Implement the plan', + ' send: true' + ].join('\n'); + const item: CompletionItem = { + label: localize('promptHeaderAutocompletion.handoffsExample', "Handoff Example"), + kind: CompletionItemKind.Value, + insertText: whilespaceAfterColon === 0 ? ` ${value}` : value, + range: new Range(position.lineNumber, colonPosition.column + whilespaceAfterColon + 1, position.lineNumber, model.getLineMaxColumn(position.lineNumber)), + }; + suggestions.push(item); + } return { suggestions }; } @@ -193,6 +209,7 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { if (property === 'model' && (promptType === PromptsType.prompt || promptType === PromptsType.mode)) { return this.getModelNames(promptType === PromptsType.mode); } + return []; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts index b4772e2e4a6..5e388a31aff 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts @@ -140,6 +140,7 @@ export class PromptValidator { case PromptsType.mode: this.validateTools(attributes, ChatModeKind.Agent, report); this.validateModel(attributes, ChatModeKind.Agent, report); + this.validateHandoffs(attributes, report); break; } @@ -306,12 +307,60 @@ export class PromptValidator { return; } } + + private validateHandoffs(attributes: IHeaderAttribute[], report: (markers: IMarkerData) => void): undefined { + const attribute = attributes.find(attr => attr.key === 'handoffs'); + if (!attribute) { + return; + } + if (attribute.value.type !== 'array') { + report(toMarker(localize('promptValidator.handoffsMustBeArray', "The 'handoffs' attribute must be an array."), attribute.value.range, MarkerSeverity.Error)); + return; + } + for (const item of attribute.value.items) { + if (item.type !== 'object') { + report(toMarker(localize('promptValidator.eachHandoffMustBeObject', "Each handoff in the 'handoffs' attribute must be an object with 'label', 'agent', 'prompt' and optional 'send'."), item.range, MarkerSeverity.Error)); + continue; + } + const required = new Set(['label', 'agent', 'prompt']); + for (const prop of item.properties) { + switch (prop.key.value) { + case 'label': + if (prop.value.type !== 'string' || prop.value.value.trim().length === 0) { + report(toMarker(localize('promptValidator.handoffLabelMustBeNonEmptyString', "The 'label' property in a handoff must be a non-empty string."), prop.value.range, MarkerSeverity.Error)); + } + break; + case 'agent': + if (prop.value.type !== 'string' || prop.value.value.trim().length === 0) { + report(toMarker(localize('promptValidator.handoffAgentMustBeNonEmptyString', "The 'agent' property in a handoff must be a non-empty string."), prop.value.range, MarkerSeverity.Error)); + } + break; + case 'prompt': + if (prop.value.type !== 'string') { + report(toMarker(localize('promptValidator.handoffPromptMustBeString', "The 'prompt' property in a handoff must be a string."), prop.value.range, MarkerSeverity.Error)); + } + break; + case 'send': + if (prop.value.type !== 'boolean') { + report(toMarker(localize('promptValidator.handoffSendMustBeBoolean', "The 'send' property in a handoff must be a boolean."), prop.value.range, MarkerSeverity.Error)); + } + break; + default: + report(toMarker(localize('promptValidator.unknownHandoffProperty', "Unknown property '{0}' in handoff object. Supported properties are 'label', 'agent', 'prompt' and optional 'send'.", prop.key.value), prop.value.range, MarkerSeverity.Warning)); + } + required.delete(prop.key.value); + } + if (required.size > 0) { + report(toMarker(localize('promptValidator.missingHandoffProperties', "Missing required properties {0} in handoff object.", Array.from(required).map(s => `'${s}'`).join(', ')), item.range, MarkerSeverity.Error)); + } + } + } } const validAttributeNames = { [PromptsType.prompt]: ['description', 'model', 'tools', 'mode'], [PromptsType.instructions]: ['description', 'applyTo', 'excludeAgent'], - [PromptsType.mode]: ['description', 'model', 'tools', 'advancedOptions'] + [PromptsType.mode]: ['description', 'model', 'tools', 'advancedOptions', 'handoffs'] }; const validAttributeNamesNoExperimental = { [PromptsType.prompt]: validAttributeNames[PromptsType.prompt].filter(name => !isExperimentalAttribute(name)), diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts index 09fa9079953..d0cb0143a72 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts @@ -180,8 +180,44 @@ export class PromptHeader { return undefined; } + public get handOffs(): IHandOff[] | undefined { + const handoffsAttribute = this._parsedHeader.attributes.find(attr => attr.key === 'handoffs'); + if (!handoffsAttribute) { + return undefined; + } + if (handoffsAttribute.value.type === 'array') { + // Array format: list of objects: { agent, label, prompt, send? } + const handoffs: IHandOff[] = []; + for (const item of handoffsAttribute.value.items) { + if (item.type === 'object') { + let agent: string | undefined; + let label: string | undefined; + let prompt: string | undefined; + let send: boolean | undefined; + for (const prop of item.properties) { + if (prop.key.value === 'agent' && prop.value.type === 'string') { + agent = prop.value.value; + } else if (prop.key.value === 'label' && prop.value.type === 'string') { + label = prop.value.value; + } else if (prop.key.value === 'prompt' && prop.value.type === 'string') { + prompt = prop.value.value; + } else if (prop.key.value === 'send' && prop.value.type === 'boolean') { + send = prop.value.value; + } + } + if (agent && label && prompt !== undefined) { + handoffs.push({ agent, label, prompt, send }); + } + } + } + return handoffs; + } + return undefined; + } } +export interface IHandOff { readonly agent: string; readonly label: string; readonly prompt: string; readonly send?: boolean } + export interface IHeaderAttribute { readonly range: Range; readonly key: string; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index 01466a2a9e4..43d12c89214 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -12,7 +12,7 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js import { PromptsType } from '../promptTypes.js'; import { createDecorator } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IChatModeInstructions } from '../../chatModes.js'; -import { ParsedPromptFile } from './newPromptsParser.js'; +import { IHandOff, ParsedPromptFile } from './newPromptsParser.js'; import { IExtensionDescription } from '../../../../../../platform/extensions/common/extensions.js'; /** @@ -106,6 +106,11 @@ export interface ICustomChatMode { * Contents of the custom chat mode file body and other mode instructions. */ readonly modeInstructions: IChatModeInstructions; + + /** + * Hand-offs defined in the custom chat mode file. + */ + readonly handOffs?: readonly IHandOff[]; } /** diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 45ae2157870..3f9b9337d46 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -282,8 +282,8 @@ export class PromptsService extends Disposable implements IPromptsService { if (!ast.header) { return { uri, name, modeInstructions }; } - const { description, model, tools } = ast.header; - return { uri, name, description, model, tools, modeInstructions }; + const { description, model, tools, handOffs } = ast.header; + return { uri, name, description, model, tools, handOffs, modeInstructions }; }) ); diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSytntax/promptValidator.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSytntax/promptValidator.test.ts index 65b54be3b98..86c89d3ba6f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSytntax/promptValidator.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSytntax/promptValidator.test.ts @@ -192,6 +192,47 @@ suite('PromptValidator', () => { assert.strictEqual(markers[0].severity, MarkerSeverity.Warning); assert.ok(markers[0].message.startsWith(`Attribute 'applyTo' is not supported in mode files.`)); }); + + test('tools with invalid handoffs', async () => { + { + const content = [ + '---', + 'description: "Test"', + `handoffs: next`, + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.mode); + assert.strictEqual(markers.length, 1); + assert.deepStrictEqual(markers.map(m => m.message), [`The 'handoffs' attribute must be an array.`]); + } + { + const content = [ + '---', + 'description: "Test"', + `handoffs:`, + ` - label: '123'`, + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.mode); + assert.strictEqual(markers.length, 1); + assert.deepStrictEqual(markers.map(m => m.message), [`Missing required properties 'agent', 'prompt' in handoff object.`]); + } + { + const content = [ + '---', + 'description: "Test"', + `handoffs:`, + ` - label: '123'`, + ` agent: ''`, + ` prompt: ''`, + ` send: true`, + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.mode); + assert.strictEqual(markers.length, 1); + assert.deepStrictEqual(markers.map(m => m.message), [`The 'agent' property in a handoff must be a non-empty string.`]); + } + }); }); suite('instructions', () => { diff --git a/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts index 9b17fbdd988..a22e793ba3d 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts @@ -130,6 +130,7 @@ suite('ChatModeService', () => { assert.strictEqual(testMode.kind, ChatModeKind.Agent); assert.deepStrictEqual(testMode.customTools?.get(), customMode.tools); assert.deepStrictEqual(testMode.modeInstructions?.get(), customMode.modeInstructions); + assert.deepStrictEqual(testMode.handOffs?.get(), customMode.handOffs); assert.strictEqual(testMode.uri?.get().toString(), customMode.uri.toString()); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts index 8a91264ae25..e500e40d69e 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts @@ -57,6 +57,66 @@ suite('NewPromptsParser', () => { assert.deepEqual(result.header.tools, ['tool1', 'tool2']); }); + test('mode with handoff', async () => { + const uri = URI.parse('file:///test/chatmode.md'); + const content = [ + /* 01 */'---', + /* 02 */`description: "Agent test"`, + /* 03 */'model: GPT 4.1', + /* 04 */'handoffs:', + /* 05 */' - label: "Implement"', + /* 06 */' agent: Default', + /* 07 */' prompt: "Implement the plan"', + /* 08 */' send: false', + /* 09 */' - label: "Save"', + /* 10 */' agent: Default', + /* 11 */' prompt: "Save the plan to a file"', + /* 12 */' send: true', + /* 13 */'---', + ].join('\n'); + const result = new NewPromptsParser().parse(uri, content); + assert.deepEqual(result.uri, uri); + assert.ok(result.header); + assert.deepEqual(result.header.range, { startLineNumber: 2, startColumn: 1, endLineNumber: 13, endColumn: 1 }); + assert.deepEqual(result.header.attributes, [ + { key: 'description', range: new Range(2, 1, 2, 26), value: { type: 'string', value: 'Agent test', range: new Range(2, 14, 2, 26) } }, + { key: 'model', range: new Range(3, 1, 3, 15), value: { type: 'string', value: 'GPT 4.1', range: new Range(3, 8, 3, 15) } }, + { + key: 'handoffs', range: new Range(4, 1, 12, 15), value: { + type: 'array', + range: new Range(5, 3, 12, 15), + items: [ + { + type: 'object', range: new Range(5, 5, 8, 16), + properties: [ + { key: { type: 'string', value: 'label', range: new Range(5, 5, 5, 10) }, value: { type: 'string', value: 'Implement', range: new Range(5, 12, 5, 23) } }, + { key: { type: 'string', value: 'agent', range: new Range(6, 5, 6, 10) }, value: { type: 'string', value: 'Default', range: new Range(6, 12, 6, 19) } }, + { key: { type: 'string', value: 'prompt', range: new Range(7, 5, 7, 11) }, value: { type: 'string', value: 'Implement the plan', range: new Range(7, 13, 7, 33) } }, + { key: { type: 'string', value: 'send', range: new Range(8, 5, 8, 9) }, value: { type: 'boolean', value: false, range: new Range(8, 11, 8, 16) } }, + ] + }, + { + type: 'object', range: new Range(9, 5, 12, 15), + properties: [ + { key: { type: 'string', value: 'label', range: new Range(9, 5, 9, 10) }, value: { type: 'string', value: 'Save', range: new Range(9, 12, 9, 18) } }, + { key: { type: 'string', value: 'agent', range: new Range(10, 5, 10, 10) }, value: { type: 'string', value: 'Default', range: new Range(10, 12, 10, 19) } }, + { key: { type: 'string', value: 'prompt', range: new Range(11, 5, 11, 11) }, value: { type: 'string', value: 'Save the plan to a file', range: new Range(11, 13, 11, 38) } }, + { key: { type: 'string', value: 'send', range: new Range(12, 5, 12, 9) }, value: { type: 'boolean', value: true, range: new Range(12, 11, 12, 15) } }, + ] + }, + ] + } + }, + ]); + assert.deepEqual(result.header.description, 'Agent test'); + assert.deepEqual(result.header.model, 'GPT 4.1'); + assert.ok(result.header.handOffs); + assert.deepEqual(result.header.handOffs, [ + { label: 'Implement', agent: 'Default', prompt: 'Implement the plan', send: false }, + { label: 'Save', agent: 'Default', prompt: 'Save the plan to a file', send: true } + ]); + }); + test('instructions', async () => { const uri = URI.parse('file:///test/prompt1.md'); const content = [ diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index f1b6d0504dd..999ea40e068 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -731,26 +731,65 @@ suite('PromptsService', () => { }); + test('header with handOffs', async () => { + const rootFolderName = 'custom-modes-with-handoffs'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await (instaService.createInstance(MockFilesystem, + [{ + name: rootFolderName, + children: [ + { + name: '.github/chatmodes', + children: [ + { + name: 'mode1.chatmode.md', + contents: [ + '---', + 'description: \'Mode file 1.\'', + 'handoffs: [ { agent: "Edit", label: "Do it", prompt: "Do it now" } ]', + '---', + ], + } + ], + + }, + ], + }])).mock(); + + const result = (await service.getCustomChatModes(CancellationToken.None)).map(mode => ({ ...mode, uri: URI.from(mode.uri) })); + const expected: ICustomChatMode[] = [ + { + name: 'mode1', + description: 'Mode file 1.', + handOffs: [{ agent: 'Edit', label: 'Do it', prompt: 'Do it now', send: undefined }], + modeInstructions: { + content: '', + toolReferences: [], + metadata: undefined + }, + model: undefined, + tools: undefined, + uri: URI.joinPath(rootFolderUri, '.github/chatmodes/mode1.chatmode.md'), + }, + ]; + + assert.deepEqual( + result, + expected, + 'Must get custom chat modes.', + ); + }); + test('body with tool references', async () => { const rootFolderName = 'custom-modes'; const rootFolder = `/${rootFolderName}`; const rootFolderUri = URI.file(rootFolder); - sinon.stub(service, 'listPromptFiles') - .returns(Promise.resolve([ - // local instructions - { - uri: URI.joinPath(rootFolderUri, '.github/chatmodes/mode1.instructions.md'), - storage: PromptsStorage.local, - type: PromptsType.mode, - }, - { - uri: URI.joinPath(rootFolderUri, '.github/chatmodes/mode2.instructions.md'), - storage: PromptsStorage.local, - type: PromptsType.instructions, - }, - - ])); + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); // mock current workspace file structure await (instaService.createInstance(MockFilesystem, @@ -761,7 +800,7 @@ suite('PromptsService', () => { name: '.github/chatmodes', children: [ { - name: 'mode1.instructions.md', + name: 'mode1.chatmode.md', contents: [ '---', 'description: \'Mode file 1.\'', @@ -771,7 +810,7 @@ suite('PromptsService', () => { ], }, { - name: 'mode2.instructions.md', + name: 'mode2.chatmode.md', contents: [ 'First use #tool2\nThen use #tool1', ], @@ -782,7 +821,7 @@ suite('PromptsService', () => { ], }])).mock(); - const result = await service.getCustomChatModes(CancellationToken.None); + const result = (await service.getCustomChatModes(CancellationToken.None)).map(mode => ({ ...mode, uri: URI.from(mode.uri) })); const expected: ICustomChatMode[] = [ { name: 'mode1', @@ -793,8 +832,9 @@ suite('PromptsService', () => { toolReferences: [{ name: 'tool1', range: { start: 11, endExclusive: 17 } }], metadata: undefined }, + handOffs: undefined, model: undefined, - uri: URI.joinPath(rootFolderUri, '.github/chatmodes/mode1.instructions.md'), + uri: URI.joinPath(rootFolderUri, '.github/chatmodes/mode1.chatmode.md'), }, { name: 'mode2', @@ -806,7 +846,7 @@ suite('PromptsService', () => { ], metadata: undefined }, - uri: URI.joinPath(rootFolderUri, '.github/chatmodes/mode2.instructions.md'), + uri: URI.joinPath(rootFolderUri, '.github/chatmodes/mode2.chatmode.md'), } ];