Skip to content
9 changes: 9 additions & 0 deletions src/vs/workbench/contrib/chat/browser/chat.contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1359,6 +1359,15 @@ configurationRegistry.registerConfiguration({
mode: 'auto'
}
},
[ChatConfiguration.GeneralPurposeAgentEnabled]: {
type: 'boolean',
description: nls.localize('chat.generalPurposeAgent.enabled', "Controls whether the built-in General Purpose agent is available as a subagent."),
default: false,
tags: ['experimental', 'advanced'],
experiment: {
mode: 'auto'
}
},
[ChatConfiguration.SubagentsAllowInvocationsFromSubagents]: {
type: 'boolean',
description: nls.localize('chat.subagents.allowInvocationsFromSubagents', "Allow subagents to invoke subagents."),
Expand Down
7 changes: 7 additions & 0 deletions src/vs/workbench/contrib/chat/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export enum ChatConfiguration {
ChatViewProgressBadgeEnabled = 'chat.viewProgressBadge.enabled',
ChatContextUsageEnabled = 'chat.contextUsage.enabled',
SubagentToolCustomAgents = 'chat.customAgentInSubagent.enabled',
GeneralPurposeAgentEnabled = 'chat.generalPurposeAgent.enabled',
SubagentsAllowInvocationsFromSubagents = 'chat.subagents.allowInvocationsFromSubagents',
ShowCodeBlockProgressAnimation = 'chat.agent.codeBlockProgress',
RestoreLastPanelSession = 'chat.restoreLastPanelSession',
Expand Down Expand Up @@ -196,3 +197,9 @@ export const ChatEditorTitleMaxLength = 30;
export const CHAT_TERMINAL_OUTPUT_MAX_PREVIEW_LINES = 1000;
export const CONTEXT_MODELS_EDITOR = new RawContextKey<boolean>('inModelsEditor', false);
export const CONTEXT_MODELS_SEARCH_FOCUS = new RawContextKey<boolean>('inModelsSearch', false);

/**
* The built-in general-purpose agent name. When the model uses this name,
* the subagent inherits the parent's system prompt, model, and tools.
*/
export const GeneralPurposeAgentName = 'General Purpose';
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { ParsedPromptFile } from './promptFileParser.js';
import { AgentInstructionFileType, IAgentSkill, ICustomAgent, IInstructionFile, IPromptsService } from './service/promptsService.js';
import { AGENT_DEBUG_LOG_ENABLED_SETTING, AGENT_DEBUG_LOG_FILE_LOGGING_ENABLED_SETTING, TROUBLESHOOT_SKILL_PATH } from './promptTypes.js';
import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js';
import { ChatConfiguration, ChatModeKind } from '../constants.js';
import { ChatConfiguration, ChatModeKind, GeneralPurposeAgentName } from '../constants.js';
import { UserSelectedTools } from '../participants/chatAgents.js';
import { hash } from '../../../../../base/common/hash.js';
import { IAgentPlugin, IAgentPluginService } from '../plugins/agentPluginService.js';
Expand Down Expand Up @@ -431,7 +431,10 @@ export class ComputeAutomaticInstructions {
entries.push('</skills>', '', ''); // add trailing newline
}
}
if (runSubagentTool && this._configurationService.getValue(ChatConfiguration.SubagentToolCustomAgents)) {
if (runSubagentTool) {
const generalPurposeAgentEnabled = !!this._configurationService.getValue<boolean>(ChatConfiguration.GeneralPurposeAgentEnabled);

const customAgentsEnabled = !!this._configurationService.getValue(ChatConfiguration.SubagentToolCustomAgents);
const canUseAgent = (() => {
if (!this._enabledSubagents || this._enabledSubagents.includes('*')) {
return (agent: ICustomAgent) => agent.visibility.agentInvocable;
Expand All @@ -440,12 +443,22 @@ export class ComputeAutomaticInstructions {
return (agent: ICustomAgent) => subagents.includes(agent.name);
}
})();
const agents = await this._promptsService.getCustomAgents(token);
if (agents.length > 0) {
const agents = customAgentsEnabled ? await this._promptsService.getCustomAgents(token) : [];

if (generalPurposeAgentEnabled || agents.length > 0) {
entries.push('<agents>');
entries.push('Here is a list of agents that can be used when running a subagent.');
entries.push('Each agent has optionally a description with the agent\'s purpose and expertise. When asked to run a subagent, choose the most appropriate agent from this list.');
entries.push(`Use the ${runSubagentTool.variable} tool with the agent name to run the subagent.`);

if (generalPurposeAgentEnabled) {
// Built-in General Purpose agent, always available when experiment is on
entries.push('<agent>');
entries.push(`<name>${GeneralPurposeAgentName}</name>`);
entries.push(`<description>Full-capability agent for complex multi-step tasks requiring high-quality reasoning. Has access to the same tools and capabilities as the current agent and inherits the parent agent's model and system prompt. Use for tasks that don't fit a more specialized agent.</description>`);
entries.push('</agent>');
}

for (const agent of agents) {
if (canUseAgent(agent)) {
entries.push('<agent>');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,20 @@

import { CancellationToken } from '../../../../../../base/common/cancellation.js';
import { Codicon } from '../../../../../../base/common/codicons.js';
import { Event } from '../../../../../../base/common/event.js';
import { Emitter, Event } from '../../../../../../base/common/event.js';
import { MarkdownString } from '../../../../../../base/common/htmlContent.js';
import { IJSONSchema, IJSONSchemaMap } from '../../../../../../base/common/jsonSchema.js';
import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js';
import { ThemeIcon } from '../../../../../../base/common/themables.js';
import { generateUuid } from '../../../../../../base/common/uuid.js';
import { localize } from '../../../../../../nls.js';
import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js';
import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js';
import { ILogService } from '../../../../../../platform/log/common/log.js';
import { IProductService } from '../../../../../../platform/product/common/productService.js';
import { ChatRequestVariableSet } from '../../attachments/chatVariableEntries.js';
import { IChatProgress, IChatService } from '../../chatService/chatService.js';
import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../constants.js';
import { ChatAgentLocation, ChatConfiguration, ChatModeKind, GeneralPurposeAgentName } from '../../constants.js';
import { ILanguageModelsService } from '../../languageModels.js';
import { ChatModel, IChatRequestModeInstructions } from '../../model/chatModel.js';
import { IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../../participants/chatAgents.js';
Expand Down Expand Up @@ -50,7 +50,8 @@ const BaseModelDescription = `Launch a new agent to handle complex, multi-step t
- When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.
- Each agent invocation is stateless. You will not be able to send additional messages to the agent, nor will the agent be able to communicate with you outside of its final report. Therefore, your prompt should contain a highly detailed task description for the agent to perform autonomously and you should specify exactly what information the agent should return back to you in its final and only message to you.
- The agent's outputs should generally be trusted
- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user\'s intent`;
- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user\'s intent
- If the user asks for a certain agent, you MUST provide that EXACT agent name (case-sensitive) to invoke that specific agent.`;

export interface IRunSubagentToolInputParams {
prompt: string;
Expand All @@ -64,7 +65,8 @@ export class RunSubagentTool extends Disposable implements IToolImpl {

static readonly Id = 'runSubagent';

readonly onDidUpdateToolData: Event<IConfigurationChangeEvent>;
private readonly _onDidUpdateToolData = this._register(new Emitter<void>());
readonly onDidUpdateToolData: Event<void> = this._onDidUpdateToolData.event;

/** Hack to port data between prepare/invoke */
private readonly _resolvedModels = new Map<string, { modeModelId: string | undefined; resolvedModelName: string | undefined }>();
Expand All @@ -78,42 +80,54 @@ export class RunSubagentTool extends Disposable implements IToolImpl {
@ILanguageModelToolsService private readonly languageModelToolsService: ILanguageModelToolsService,
@ILanguageModelsService private readonly languageModelsService: ILanguageModelsService,
@ILogService private readonly logService: ILogService,
@ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService,
@IConfigurationService private readonly configurationService: IConfigurationService,
@IPromptsService private readonly promptsService: IPromptsService,
@IInstantiationService private readonly instantiationService: IInstantiationService,
@IProductService private readonly productService: IProductService,
) {
super();
this.onDidUpdateToolData = Event.filter(this.configurationService.onDidChangeConfiguration, e =>
e.affectsConfiguration(ChatConfiguration.SubagentToolCustomAgents)
);

this._register(Event.filter(this.configurationService.onDidChangeConfiguration, e =>
e.affectsConfiguration(ChatConfiguration.SubagentToolCustomAgents) ||
e.affectsConfiguration(ChatConfiguration.GeneralPurposeAgentEnabled)
)(() => this._onDidUpdateToolData.fire()));
}

getToolData(): IToolData {
let modelDescription = BaseModelDescription;
const inputSchema: IJSONSchema & { properties: IJSONSchemaMap } = {
type: 'object',
properties: {
prompt: {
type: 'string',
description: 'A detailed description of the task for the agent to perform'
},
description: {
type: 'string',
description: 'A short (3-5 word) description of the task'
}
const modelDescription = BaseModelDescription;
const generalPurposeAgentEnabled = this.configurationService.getValue<boolean>(ChatConfiguration.GeneralPurposeAgentEnabled);
const customAgentsEnabled = this.configurationService.getValue<boolean>(ChatConfiguration.SubagentToolCustomAgents);

const properties: IJSONSchemaMap = {
prompt: {
type: 'string',
description: 'A detailed description of the task for the agent to perform'
},
required: ['prompt', 'description']
description: {
type: 'string',
description: 'A short (3-5 word) description of the task'
}
};

if (this.configurationService.getValue(ChatConfiguration.SubagentToolCustomAgents)) {
inputSchema.properties.agentName = {
if (customAgentsEnabled || generalPurposeAgentEnabled) {
properties.agentName = {
type: 'string',
description: 'Optional name of a specific agent to invoke. If not provided, uses the current agent.'
description: generalPurposeAgentEnabled
? 'Name of the agent to invoke.'
: 'Optional name of a specific agent to invoke. If not provided, uses the current agent.'
};
modelDescription += `\n- If the user asks for a certain agent, you MUST provide that EXACT agent name (case-sensitive) to invoke that specific agent.`;
}

const required: string[] = ['prompt', 'description'];
if (generalPurposeAgentEnabled) {
required.push('agentName');
}

const inputSchema: IJSONSchema & { properties: IJSONSchemaMap } = {
type: 'object',
properties,
required
};
Comment thread
digitarald marked this conversation as resolved.
const runSubagentToolData: IToolData = {
id: RunSubagentTool.Id,
toolReferenceName: VSCodeToolReference.runSubagent,
Expand Down Expand Up @@ -161,8 +175,14 @@ export class RunSubagentTool extends Disposable implements IToolImpl {
let resolvedModelName: string | undefined;

const subAgentName = args.agentName;
if (subAgentName) {
subagent = await this.getSubAgentByName(subAgentName);
// Defensive: model may omit agentName despite schema requiring it
const gpEnabled = this.configurationService.getValue<boolean>(ChatConfiguration.GeneralPurposeAgentEnabled);
const customAgentsEnabled = this.configurationService.getValue<boolean>(ChatConfiguration.SubagentToolCustomAgents);
const isGeneralPurpose = gpEnabled && (!subAgentName || subAgentName === GeneralPurposeAgentName);
const effectiveSubAgentName = isGeneralPurpose ? GeneralPurposeAgentName : subAgentName;

if (subAgentName && !isGeneralPurpose) {
Comment thread
digitarald marked this conversation as resolved.
subagent = customAgentsEnabled ? await this.getSubAgentByName(subAgentName) : undefined;
if (subagent) {
// Check the pre-resolved model cache from prepareToolInvocation
const cached = this._resolvedModels.get(invocation.callId);
Expand Down Expand Up @@ -195,12 +215,15 @@ export class RunSubagentTool extends Disposable implements IToolImpl {
modeInstructions = instructions && {
name: subAgentName,
content: instructions.content,
toolReferences: this.toolsService.toToolReferences(instructions.toolReferences),
toolReferences: this.languageModelToolsService.toToolReferences(instructions.toolReferences),
metadata: instructions.metadata,
isBuiltin: isBuiltinAgent(subagent.source, subagent.uri, this.productService),
};
} else {
throw new Error(`Requested agent '${subAgentName}' not found. Try again with the correct agent name, or omit the agentName to use the current agent.`);
this._resolvedModels.delete(invocation.callId);
const baseHint = ' Try again with the correct agent name, or omit agentName to use the current agent.';
const gpHint = gpEnabled ? ` Additionally, you can use '${GeneralPurposeAgentName}' for a full-capability agent.` : '';
throw new Error(`Requested agent '${subAgentName}' not found.${baseHint}${gpHint}`);
}
} else {
// No subagent name - clean up any cached entry and resolve model name from main model
Expand Down Expand Up @@ -312,7 +335,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl {
variables: { variables: variableSet.asArray() },
location: ChatAgentLocation.Chat,
subAgentInvocationId: subAgentInvocationId,
subAgentName: subAgentName,
subAgentName: effectiveSubAgentName,
userSelectedModelId: modeModelId,
modelConfiguration: modeModelId ? this.languageModelsService.getModelConfiguration(modeModelId) : undefined,
userSelectedTools: modeTools,
Expand Down Expand Up @@ -433,7 +456,11 @@ export class RunSubagentTool extends Disposable implements IToolImpl {
async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise<IPreparedToolInvocation | undefined> {
const args = context.parameters as IRunSubagentToolInputParams;

const subagent = args.agentName ? await this.getSubAgentByName(args.agentName) : undefined;
// Defensive: model may omit agentName despite schema requiring it
const gpEnabled = this.configurationService.getValue<boolean>(ChatConfiguration.GeneralPurposeAgentEnabled);
const customAgentsEnabled = this.configurationService.getValue<boolean>(ChatConfiguration.SubagentToolCustomAgents);
const isGeneralPurpose = gpEnabled && (!args.agentName || args.agentName === GeneralPurposeAgentName);
const subagent = (args.agentName && !isGeneralPurpose && customAgentsEnabled) ? await this.getSubAgentByName(args.agentName) : undefined;

// Resolve the model early and cache it for invoke()
const resolved = this.resolveSubagentModel(subagent, context.modelId);
Expand All @@ -444,7 +471,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl {
toolSpecificData: {
kind: 'subagent',
description: args.description,
agentName: subagent?.name,
agentName: isGeneralPurpose ? GeneralPurposeAgentName : (subagent?.name ?? args.agentName),
prompt: args.prompt,
modelName: resolved.resolvedModelName,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import { ILanguageModelToolsService } from '../../../common/tools/languageModelT
import { IRemoteAgentService } from '../../../../../../workbench/services/remote/common/remoteAgentService.js';
import { basename } from '../../../../../../base/common/resources.js';
import { match } from '../../../../../../base/common/glob.js';
import { ChatModeKind } from '../../../common/constants.js';
import { ChatModeKind, GeneralPurposeAgentName } from '../../../common/constants.js';
import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js';
import { MockContextKeyService } from '../../../../../../platform/keybinding/test/common/mockKeybindingService.js';
import { IAgentPlugin, IAgentPluginService } from '../../../common/plugins/agentPluginService.js';
Expand Down Expand Up @@ -1505,6 +1505,87 @@ suite('ComputeAutomaticInstructions', () => {
assert.equal(xmlContents(agents[2], 'name')[0], `test-agent-5`);
});

test('should include General Purpose agent first when experiment is enabled', async () => {
const rootFolderName = 'gp-agents-list-test';
const rootFolder = `/${rootFolderName}`;
const rootFolderUri = URI.file(rootFolder);

workspaceContextService.setWorkspace(testWorkspace(rootFolderUri));

testConfigService.setUserConfiguration('chat.customAgentInSubagent.enabled', true);

testConfigService.setUserConfiguration('chat.generalPurposeAgent.enabled', true);

testConfigService.setUserConfiguration(PromptsConfig.AGENTS_LOCATION_KEY, {
[AGENTS_SOURCE_FOLDER]: true,
});

await mockFiles(fileService, [
{
path: `${rootFolder}/.github/agents/test-agent-1.agent.md`,
contents: [
'---',
'description: \'Test agent 1\'',
'---',
'Test agent content',
]
},
]);

const contextComputer = instaService.createInstance(
ComputeAutomaticInstructions,
ChatModeKind.Agent,
{ 'vscode_runSubagent': true },
['*'],
);
const variables = new ChatRequestVariableSet();

await contextComputer.collect(variables, CancellationToken.None);

const textVariables = variables.asArray().filter(v => isPromptTextVariableEntry(v));
assert.equal(textVariables.length, 1, 'There should be one text variable for agents list');

const agentsList = xmlContents(textVariables[0].value, 'agents');
assert.equal(agentsList.length, 1, 'There should be one agents list');

const agents = xmlContents(agentsList[0], 'agent');
assert.equal(agents.length, 2, 'There should be two agents (General Purpose + 1 custom)');

// First agent should always be the built-in General Purpose agent
assert.equal(xmlContents(agents[0], 'name')[0], GeneralPurposeAgentName);

assert.equal(xmlContents(agents[1], 'name')[0], 'test-agent-1');
assert.equal(xmlContents(agents[1], 'description')[0], 'Test agent 1');
});

test('should include General Purpose agent even without custom agents config', async () => {
workspaceContextService.setWorkspace(testWorkspace(URI.file('/gp-only-test')));

// Explicitly do NOT set chat.customAgentInSubagent.enabled

testConfigService.setUserConfiguration('chat.generalPurposeAgent.enabled', true);

const contextComputer = instaService.createInstance(
ComputeAutomaticInstructions,
ChatModeKind.Agent,
{ 'vscode_runSubagent': true },
['*'],
);
const variables = new ChatRequestVariableSet();

await contextComputer.collect(variables, CancellationToken.None);

const textVariables = variables.asArray().filter(v => isPromptTextVariableEntry(v));
assert.equal(textVariables.length, 1, 'There should be one text variable for agents list');

const agentsList = xmlContents(textVariables[0].value, 'agents');
assert.equal(agentsList.length, 1, 'There should be one agents list');

const agents = xmlContents(agentsList[0], 'agent');
assert.equal(agents.length, 1, 'There should be only the GP agent');
assert.equal(xmlContents(agents[0], 'name')[0], GeneralPurposeAgentName);
});

test('should include skills list when readFile tool available', async () => {
const rootFolderName = 'skills-list-test';
const rootFolder = `/${rootFolderName}`;
Expand Down
Loading
Loading