feat: enable subagents (#22386)

This commit is contained in:
Abhi
2026-03-16 14:40:12 -04:00
committed by GitHub
parent 56e0865a7b
commit d43ec6c8f3
13 changed files with 111 additions and 59 deletions
+2 -8
View File
@@ -7,20 +7,14 @@ the main agent's context or toolset.
> **Note: Subagents are currently an experimental feature.** > **Note: Subagents are currently an experimental feature.**
> >
> To use custom subagents, you must explicitly enable them in your > To use custom subagents, you must ensure they are enabled in your
> `settings.json`: > `settings.json` (enabled by default):
> >
> ```json > ```json
> { > {
> "experimental": { "enableAgents": true } > "experimental": { "enableAgents": true }
> } > }
> ``` > ```
>
> **Warning:** Subagents currently operate in
> ["YOLO mode"](../reference/configuration.md#command-line-arguments), meaning
> they may execute tools without individual user confirmation for each step.
> Proceed with caution when defining agents with powerful tools like
> `run_shell_command` or `write_file`.
## What are subagents? ## What are subagents?
+2 -3
View File
@@ -1158,9 +1158,8 @@ their corresponding top-level category object in your `settings.json` file.
- **Requires restart:** Yes - **Requires restart:** Yes
- **`experimental.enableAgents`** (boolean): - **`experimental.enableAgents`** (boolean):
- **Description:** Enable local and remote subagents. Warning: Experimental - **Description:** Enable local and remote subagents.
feature, uses YOLO mode for subagents - **Default:** `true`
- **Default:** `false`
- **Requires restart:** Yes - **Requires restart:** Yes
- **`experimental.extensionManagement`** (boolean): - **`experimental.extensionManagement`** (boolean):
@@ -137,6 +137,7 @@ describe('handleInstall', () => {
mcps: [], mcps: [],
hooks: [], hooks: [],
skills: [], skills: [],
agents: [],
settings: [], settings: [],
securityWarnings: [], securityWarnings: [],
discoveryErrors: [], discoveryErrors: [],
@@ -379,6 +380,7 @@ describe('handleInstall', () => {
mcps: [], mcps: [],
hooks: [], hooks: [],
skills: ['cool-skill'], skills: ['cool-skill'],
agents: ['cool-agent'],
settings: [], settings: [],
securityWarnings: ['Security risk!'], securityWarnings: ['Security risk!'],
discoveryErrors: ['Read error'], discoveryErrors: ['Read error'],
@@ -408,6 +410,10 @@ describe('handleInstall', () => {
expect.stringContaining('cool-skill'), expect.stringContaining('cool-skill'),
false, false,
); );
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
expect.stringContaining('cool-agent'),
false,
);
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith( expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
expect.stringContaining('Security Warnings:'), expect.stringContaining('Security Warnings:'),
false, false,
@@ -99,11 +99,15 @@ export async function handleInstall(args: InstallArgs) {
if (hasDiscovery) { if (hasDiscovery) {
promptLines.push(chalk.bold('This folder contains:')); promptLines.push(chalk.bold('This folder contains:'));
const groups = [ const groups = [
{ label: 'Commands', items: discoveryResults.commands }, { label: 'Commands', items: discoveryResults.commands ?? [] },
{ label: 'MCP Servers', items: discoveryResults.mcps }, { label: 'MCP Servers', items: discoveryResults.mcps ?? [] },
{ label: 'Hooks', items: discoveryResults.hooks }, { label: 'Hooks', items: discoveryResults.hooks ?? [] },
{ label: 'Skills', items: discoveryResults.skills }, { label: 'Skills', items: discoveryResults.skills ?? [] },
{ label: 'Setting overrides', items: discoveryResults.settings }, { label: 'Agents', items: discoveryResults.agents ?? [] },
{
label: 'Setting overrides',
items: discoveryResults.settings ?? [],
},
].filter((g) => g.items.length > 0); ].filter((g) => g.items.length > 0);
for (const group of groups) { for (const group of groups) {
@@ -400,12 +400,10 @@ describe('SettingsSchema', () => {
expect(setting).toBeDefined(); expect(setting).toBeDefined();
expect(setting.type).toBe('boolean'); expect(setting.type).toBe('boolean');
expect(setting.category).toBe('Experimental'); expect(setting.category).toBe('Experimental');
expect(setting.default).toBe(false); expect(setting.default).toBe(true);
expect(setting.requiresRestart).toBe(true); expect(setting.requiresRestart).toBe(true);
expect(setting.showInDialog).toBe(false); expect(setting.showInDialog).toBe(false);
expect(setting.description).toBe( expect(setting.description).toBe('Enable local and remote subagents.');
'Enable local and remote subagents. Warning: Experimental feature, uses YOLO mode for subagents',
);
}); });
it('should have skills setting enabled by default', () => { it('should have skills setting enabled by default', () => {
+2 -3
View File
@@ -1838,9 +1838,8 @@ const SETTINGS_SCHEMA = {
label: 'Enable Agents', label: 'Enable Agents',
category: 'Experimental', category: 'Experimental',
requiresRestart: true, requiresRestart: true,
default: false, default: true,
description: description: 'Enable local and remote subagents.',
'Enable local and remote subagents. Warning: Experimental feature, uses YOLO mode for subagents',
showInDialog: false, showInDialog: false,
}, },
extensionManagement: { extensionManagement: {
@@ -66,6 +66,7 @@ describe('FolderTrustDialog', () => {
mcps: Array.from({ length: 10 }, (_, i) => `mcp${i}`), mcps: Array.from({ length: 10 }, (_, i) => `mcp${i}`),
hooks: Array.from({ length: 10 }, (_, i) => `hook${i}`), hooks: Array.from({ length: 10 }, (_, i) => `hook${i}`),
skills: Array.from({ length: 10 }, (_, i) => `skill${i}`), skills: Array.from({ length: 10 }, (_, i) => `skill${i}`),
agents: [],
settings: Array.from({ length: 10 }, (_, i) => `setting${i}`), settings: Array.from({ length: 10 }, (_, i) => `setting${i}`),
discoveryErrors: [], discoveryErrors: [],
securityWarnings: [], securityWarnings: [],
@@ -95,6 +96,7 @@ describe('FolderTrustDialog', () => {
mcps: [], mcps: [],
hooks: [], hooks: [],
skills: [], skills: [],
agents: [],
settings: [], settings: [],
discoveryErrors: [], discoveryErrors: [],
securityWarnings: [], securityWarnings: [],
@@ -125,6 +127,7 @@ describe('FolderTrustDialog', () => {
mcps: [], mcps: [],
hooks: [], hooks: [],
skills: [], skills: [],
agents: [],
settings: [], settings: [],
discoveryErrors: [], discoveryErrors: [],
securityWarnings: [], securityWarnings: [],
@@ -152,6 +155,7 @@ describe('FolderTrustDialog', () => {
mcps: [], mcps: [],
hooks: [], hooks: [],
skills: [], skills: [],
agents: [],
settings: [], settings: [],
discoveryErrors: [], discoveryErrors: [],
securityWarnings: [], securityWarnings: [],
@@ -332,6 +336,7 @@ describe('FolderTrustDialog', () => {
mcps: ['mcp1'], mcps: ['mcp1'],
hooks: ['hook1'], hooks: ['hook1'],
skills: ['skill1'], skills: ['skill1'],
agents: ['agent1'],
settings: ['general', 'ui'], settings: ['general', 'ui'],
discoveryErrors: [], discoveryErrors: [],
securityWarnings: [], securityWarnings: [],
@@ -355,6 +360,8 @@ describe('FolderTrustDialog', () => {
expect(lastFrame()).toContain('- hook1'); expect(lastFrame()).toContain('- hook1');
expect(lastFrame()).toContain('• Skills (1):'); expect(lastFrame()).toContain('• Skills (1):');
expect(lastFrame()).toContain('- skill1'); expect(lastFrame()).toContain('- skill1');
expect(lastFrame()).toContain('• Agents (1):');
expect(lastFrame()).toContain('- agent1');
expect(lastFrame()).toContain('• Setting overrides (2):'); expect(lastFrame()).toContain('• Setting overrides (2):');
expect(lastFrame()).toContain('- general'); expect(lastFrame()).toContain('- general');
expect(lastFrame()).toContain('- ui'); expect(lastFrame()).toContain('- ui');
@@ -367,6 +374,7 @@ describe('FolderTrustDialog', () => {
mcps: [], mcps: [],
hooks: [], hooks: [],
skills: [], skills: [],
agents: [],
settings: [], settings: [],
discoveryErrors: [], discoveryErrors: [],
securityWarnings: ['Dangerous setting detected!'], securityWarnings: ['Dangerous setting detected!'],
@@ -390,6 +398,7 @@ describe('FolderTrustDialog', () => {
mcps: [], mcps: [],
hooks: [], hooks: [],
skills: [], skills: [],
agents: [],
settings: [], settings: [],
discoveryErrors: ['Failed to load custom commands'], discoveryErrors: ['Failed to load custom commands'],
securityWarnings: [], securityWarnings: [],
@@ -413,6 +422,7 @@ describe('FolderTrustDialog', () => {
mcps: [], mcps: [],
hooks: [], hooks: [],
skills: [], skills: [],
agents: [],
settings: [], settings: [],
discoveryErrors: [], discoveryErrors: [],
securityWarnings: [], securityWarnings: [],
@@ -446,6 +456,7 @@ describe('FolderTrustDialog', () => {
mcps: [`${ansiRed}mcp-with-ansi${ansiReset}`], mcps: [`${ansiRed}mcp-with-ansi${ansiReset}`],
hooks: [`${ansiRed}hook-with-ansi${ansiReset}`], hooks: [`${ansiRed}hook-with-ansi${ansiReset}`],
skills: [`${ansiRed}skill-with-ansi${ansiReset}`], skills: [`${ansiRed}skill-with-ansi${ansiReset}`],
agents: [],
settings: [`${ansiRed}setting-with-ansi${ansiReset}`], settings: [`${ansiRed}setting-with-ansi${ansiReset}`],
discoveryErrors: [`${ansiRed}error-with-ansi${ansiReset}`], discoveryErrors: [`${ansiRed}error-with-ansi${ansiReset}`],
securityWarnings: [`${ansiRed}warning-with-ansi${ansiReset}`], securityWarnings: [`${ansiRed}warning-with-ansi${ansiReset}`],
@@ -135,6 +135,7 @@ export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
{ label: 'MCP Servers', items: discoveryResults?.mcps ?? [] }, { label: 'MCP Servers', items: discoveryResults?.mcps ?? [] },
{ label: 'Hooks', items: discoveryResults?.hooks ?? [] }, { label: 'Hooks', items: discoveryResults?.hooks ?? [] },
{ label: 'Skills', items: discoveryResults?.skills ?? [] }, { label: 'Skills', items: discoveryResults?.skills ?? [] },
{ label: 'Agents', items: discoveryResults?.agents ?? [] },
{ label: 'Setting overrides', items: discoveryResults?.settings ?? [] }, { label: 'Setting overrides', items: discoveryResults?.settings ?? [] },
].filter((g) => g.items.length > 0); ].filter((g) => g.items.length > 0);
+3 -3
View File
@@ -1246,7 +1246,7 @@ describe('Server Config (config.ts)', () => {
const config = new Config(params); const config = new Config(params);
const mockAgentDefinition = { const mockAgentDefinition = {
name: 'codebase-investigator', name: 'codebase_investigator',
description: 'Agent 1', description: 'Agent 1',
instructions: 'Inst 1', instructions: 'Inst 1',
}; };
@@ -1294,7 +1294,7 @@ describe('Server Config (config.ts)', () => {
it('should register subagents as tools even when they are not in allowedTools', async () => { it('should register subagents as tools even when they are not in allowedTools', async () => {
const params: ConfigParameters = { const params: ConfigParameters = {
...baseParams, ...baseParams,
allowedTools: ['read_file'], // codebase-investigator is NOT here allowedTools: ['read_file'], // codebase_investigator is NOT here
agents: { agents: {
overrides: { overrides: {
codebase_investigator: { enabled: true }, codebase_investigator: { enabled: true },
@@ -1304,7 +1304,7 @@ describe('Server Config (config.ts)', () => {
const config = new Config(params); const config = new Config(params);
const mockAgentDefinition = { const mockAgentDefinition = {
name: 'codebase-investigator', name: 'codebase_investigator',
description: 'Agent 1', description: 'Agent 1',
instructions: 'Inst 1', instructions: 'Inst 1',
}; };
+16 -15
View File
@@ -948,7 +948,7 @@ export class Config implements McpContext, AgentLoopContext {
this.model = params.model; this.model = params.model;
this.disableLoopDetection = params.disableLoopDetection ?? false; this.disableLoopDetection = params.disableLoopDetection ?? false;
this._activeModel = params.model; this._activeModel = params.model;
this.enableAgents = params.enableAgents ?? false; this.enableAgents = params.enableAgents ?? true;
this.agents = params.agents ?? {}; this.agents = params.agents ?? {};
this.disableLLMCorrection = params.disableLLMCorrection ?? true; this.disableLLMCorrection = params.disableLLMCorrection ?? true;
this.planEnabled = params.plan ?? true; this.planEnabled = params.plan ?? true;
@@ -3147,22 +3147,23 @@ export class Config implements McpContext, AgentLoopContext {
*/ */
private registerSubAgentTools(registry: ToolRegistry): void { private registerSubAgentTools(registry: ToolRegistry): void {
const agentsOverrides = this.getAgentsSettings().overrides ?? {}; const agentsOverrides = this.getAgentsSettings().overrides ?? {};
if ( const definitions = this.agentRegistry.getAllDefinitions();
this.isAgentsEnabled() ||
agentsOverrides['codebase_investigator']?.enabled !== false ||
agentsOverrides['cli_help']?.enabled !== false
) {
const definitions = this.agentRegistry.getAllDefinitions();
for (const definition of definitions) { for (const definition of definitions) {
try { try {
const tool = new SubagentTool(definition, this, this.messageBus); if (
registry.registerTool(tool); !this.isAgentsEnabled() ||
} catch (e: unknown) { agentsOverrides[definition.name]?.enabled === false
debugLogger.warn( ) {
`Failed to register tool for agent ${definition.name}: ${getErrorMessage(e)}`, continue;
);
} }
const tool = new SubagentTool(definition, this, this.messageBus);
registry.registerTool(tool);
} catch (e: unknown) {
debugLogger.warn(
`Failed to register tool for agent ${definition.name}: ${getErrorMessage(e)}`,
);
} }
} }
} }
@@ -42,6 +42,11 @@ describe('FolderTrustDiscoveryService', () => {
await fs.mkdir(path.join(skillsDir, 'test-skill'), { recursive: true }); await fs.mkdir(path.join(skillsDir, 'test-skill'), { recursive: true });
await fs.writeFile(path.join(skillsDir, 'test-skill', 'SKILL.md'), 'body'); await fs.writeFile(path.join(skillsDir, 'test-skill', 'SKILL.md'), 'body');
// Mock agents
const agentsDir = path.join(geminiDir, 'agents');
await fs.mkdir(agentsDir);
await fs.writeFile(path.join(agentsDir, 'test-agent.md'), 'body');
// Mock settings (MCPs, Hooks, and general settings) // Mock settings (MCPs, Hooks, and general settings)
const settings = { const settings = {
mcpServers: { mcpServers: {
@@ -62,6 +67,7 @@ describe('FolderTrustDiscoveryService', () => {
expect(results.commands).toContain('test-cmd'); expect(results.commands).toContain('test-cmd');
expect(results.skills).toContain('test-skill'); expect(results.skills).toContain('test-skill');
expect(results.agents).toContain('test-agent');
expect(results.mcps).toContain('test-mcp'); expect(results.mcps).toContain('test-mcp');
expect(results.hooks).toContain('test-hook'); expect(results.hooks).toContain('test-hook');
expect(results.settings).toContain('general'); expect(results.settings).toContain('general');
@@ -79,9 +85,6 @@ describe('FolderTrustDiscoveryService', () => {
allowed: ['git'], allowed: ['git'],
sandbox: false, sandbox: false,
}, },
experimental: {
enableAgents: true,
},
security: { security: {
folderTrust: { folderTrust: {
enabled: false, enabled: false,
@@ -98,9 +101,6 @@ describe('FolderTrustDiscoveryService', () => {
expect(results.securityWarnings).toContain( expect(results.securityWarnings).toContain(
'This project auto-approves certain tools (tools.allowed).', 'This project auto-approves certain tools (tools.allowed).',
); );
expect(results.securityWarnings).toContain(
'This project enables autonomous agents (enableAgents).',
);
expect(results.securityWarnings).toContain( expect(results.securityWarnings).toContain(
'This project attempts to disable folder trust (security.folderTrust.enabled).', 'This project attempts to disable folder trust (security.folderTrust.enabled).',
); );
@@ -158,4 +158,20 @@ describe('FolderTrustDiscoveryService', () => {
expect(results.discoveryErrors).toHaveLength(0); expect(results.discoveryErrors).toHaveLength(0);
expect(results.settings).toHaveLength(0); expect(results.settings).toHaveLength(0);
}); });
it('should flag security warning for custom agents', async () => {
const geminiDir = path.join(tempDir, GEMINI_DIR);
await fs.mkdir(geminiDir, { recursive: true });
const agentsDir = path.join(geminiDir, 'agents');
await fs.mkdir(agentsDir);
await fs.writeFile(path.join(agentsDir, 'test-agent.md'), 'body');
const results = await FolderTrustDiscoveryService.discover(tempDir);
expect(results.agents).toContain('test-agent');
expect(results.securityWarnings).toContain(
'This project contains custom agents.',
);
});
}); });
@@ -16,6 +16,7 @@ export interface FolderDiscoveryResults {
mcps: string[]; mcps: string[];
hooks: string[]; hooks: string[];
skills: string[]; skills: string[];
agents: string[];
settings: string[]; settings: string[];
securityWarnings: string[]; securityWarnings: string[];
discoveryErrors: string[]; discoveryErrors: string[];
@@ -37,6 +38,7 @@ export class FolderTrustDiscoveryService {
mcps: [], mcps: [],
hooks: [], hooks: [],
skills: [], skills: [],
agents: [],
settings: [], settings: [],
securityWarnings: [], securityWarnings: [],
discoveryErrors: [], discoveryErrors: [],
@@ -50,6 +52,7 @@ export class FolderTrustDiscoveryService {
await Promise.all([ await Promise.all([
this.discoverCommands(geminiDir, results), this.discoverCommands(geminiDir, results),
this.discoverSkills(geminiDir, results), this.discoverSkills(geminiDir, results),
this.discoverAgents(geminiDir, results),
this.discoverSettings(geminiDir, results), this.discoverSettings(geminiDir, results),
]); ]);
@@ -99,6 +102,34 @@ export class FolderTrustDiscoveryService {
} }
} }
private static async discoverAgents(
geminiDir: string,
results: FolderDiscoveryResults,
) {
const agentsDir = path.join(geminiDir, 'agents');
if (await this.exists(agentsDir)) {
try {
const entries = await fs.readdir(agentsDir, { withFileTypes: true });
for (const entry of entries) {
if (
entry.isFile() &&
entry.name.endsWith('.md') &&
!entry.name.startsWith('_')
) {
results.agents.push(path.basename(entry.name, '.md'));
}
}
if (results.agents.length > 0) {
results.securityWarnings.push('This project contains custom agents.');
}
} catch (e) {
results.discoveryErrors.push(
`Failed to discover agents: ${e instanceof Error ? e.message : String(e)}`,
);
}
}
}
private static async discoverSettings( private static async discoverSettings(
geminiDir: string, geminiDir: string,
results: FolderDiscoveryResults, results: FolderDiscoveryResults,
@@ -119,7 +150,7 @@ export class FolderTrustDiscoveryService {
(key) => !['mcpServers', 'hooks', '$schema'].includes(key), (key) => !['mcpServers', 'hooks', '$schema'].includes(key),
); );
results.securityWarnings = this.collectSecurityWarnings(settings); results.securityWarnings.push(...this.collectSecurityWarnings(settings));
const mcpServers = settings['mcpServers']; const mcpServers = settings['mcpServers'];
if (this.isRecord(mcpServers)) { if (this.isRecord(mcpServers)) {
@@ -159,10 +190,6 @@ export class FolderTrustDiscoveryService {
? settings['tools'] ? settings['tools']
: undefined; : undefined;
const experimental = this.isRecord(settings['experimental'])
? settings['experimental']
: undefined;
const security = this.isRecord(settings['security']) const security = this.isRecord(settings['security'])
? settings['security'] ? settings['security']
: undefined; : undefined;
@@ -179,10 +206,6 @@ export class FolderTrustDiscoveryService {
condition: Array.isArray(allowedTools) && allowedTools.length > 0, condition: Array.isArray(allowedTools) && allowedTools.length > 0,
message: 'This project auto-approves certain tools (tools.allowed).', message: 'This project auto-approves certain tools (tools.allowed).',
}, },
{
condition: experimental?.['enableAgents'] === true,
message: 'This project enables autonomous agents (enableAgents).',
},
{ {
condition: folderTrust?.['enabled'] === false, condition: folderTrust?.['enabled'] === false,
message: message:
+3 -3
View File
@@ -1970,9 +1970,9 @@
}, },
"enableAgents": { "enableAgents": {
"title": "Enable Agents", "title": "Enable Agents",
"description": "Enable local and remote subagents. Warning: Experimental feature, uses YOLO mode for subagents", "description": "Enable local and remote subagents.",
"markdownDescription": "Enable local and remote subagents. Warning: Experimental feature, uses YOLO mode for subagents\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`", "markdownDescription": "Enable local and remote subagents.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `true`",
"default": false, "default": true,
"type": "boolean" "type": "boolean"
}, },
"extensionManagement": { "extensionManagement": {