support handoff property in modes (#271710)

* support handoff property in modes

* allow empty prompt

* Update src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/vs/workbench/contrib/chat/common/promptSyntax/service/newPromptsParser.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix nls key

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Martin Aeschlimann
2025-10-16 23:45:26 +02:00
committed by GitHub
parent 56850a2386
commit 0df032fb9f
10 changed files with 287 additions and 26 deletions
@@ -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<IChatModeService>('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 string[] | undefined>;
readonly handOffs?: IObservable<readonly IHandOff[] | undefined>;
readonly model?: IObservable<string | undefined>;
readonly modeInstructions?: IObservable<IChatModeInstructions>;
readonly uri?: IObservable<URI>;
@@ -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<IChatModeInstructions>;
private readonly _uriObservable: ISettableObservable<URI>;
private readonly _modelObservable: ISettableObservable<string | undefined>;
private readonly _handoffsObservable: ISettableObservable<readonly IHandOff[] | undefined>;
public readonly id: string;
public readonly name: string;
@@ -283,6 +289,10 @@ export class CustomChatMode implements IChatMode {
return this.name;
}
get handOffs(): IObservable<readonly IHandOff[] | undefined> {
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()
};
}
}
@@ -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 [];
}
@@ -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)),
@@ -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;
@@ -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[];
}
/**
@@ -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 };
})
);
@@ -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', () => {
@@ -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());
});
@@ -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 = [
@@ -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'),
}
];