From aa524625503ff15029c744864936afb55076d6e9 Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Tue, 13 Jan 2026 19:09:22 +0000 Subject: [PATCH] Implement support for subagents as extensions. (#16473) --- .../config/extension-manager-agents.test.ts | 140 ++++++++++++++++++ packages/cli/src/config/extension-manager.ts | 13 ++ packages/core/src/agents/registry.test.ts | 58 +++++++- packages/core/src/agents/registry.ts | 12 +- packages/core/src/config/config.ts | 2 + packages/core/src/index.ts | 1 + 6 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/config/extension-manager-agents.test.ts diff --git a/packages/cli/src/config/extension-manager-agents.test.ts b/packages/cli/src/config/extension-manager-agents.test.ts new file mode 100644 index 0000000000..936d3fea10 --- /dev/null +++ b/packages/cli/src/config/extension-manager-agents.test.ts @@ -0,0 +1,140 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { ExtensionManager } from './extension-manager.js'; +import { debugLogger } from '@google/gemini-cli-core'; +import { type Settings } from './settings.js'; +import { createExtension } from '../test-utils/createExtension.js'; +import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js'; + +const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home')); + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: mockHomedir, + }; +}); + +// Mock @google/gemini-cli-core +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + homedir: mockHomedir, + }; +}); + +describe('ExtensionManager agents loading', () => { + let extensionManager: ExtensionManager; + let tempDir: string; + let extensionsDir: string; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(debugLogger, 'warn').mockImplementation(() => {}); + + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-test-agents-')); + mockHomedir.mockReturnValue(tempDir); + + // Create the extensions directory that ExtensionManager expects + extensionsDir = path.join(tempDir, '.gemini', EXTENSIONS_DIRECTORY_NAME); + fs.mkdirSync(extensionsDir, { recursive: true }); + + extensionManager = new ExtensionManager({ + settings: { + telemetry: { enabled: false }, + trustedFolders: [tempDir], + } as unknown as Settings, + requestConsent: vi.fn().mockResolvedValue(true), + requestSetting: vi.fn(), + workspaceDir: tempDir, + }); + }); + + afterEach(() => { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // ignore + } + }); + + it('should load agents from an extension', async () => { + const sourceDir = path.join(tempDir, 'source-ext-good'); + createExtension({ + extensionsDir: sourceDir, + name: 'good-agents-ext', + version: '1.0.0', + installMetadata: { + type: 'local', + source: path.join(sourceDir, 'good-agents-ext'), + }, + }); + const extensionPath = path.join(sourceDir, 'good-agents-ext'); + + const agentsDir = path.join(extensionPath, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync( + path.join(agentsDir, 'test-agent.md'), + '---\nname: test-agent\nkind: local\ndescription: test desc\n---\nbody', + ); + + await extensionManager.loadExtensions(); + + const extension = await extensionManager.installOrUpdateExtension({ + type: 'local', + source: extensionPath, + }); + + expect(extension.name).toBe('good-agents-ext'); + expect(extension.agents).toBeDefined(); + expect(extension.agents).toHaveLength(1); + expect(extension.agents![0].name).toBe('test-agent'); + expect(debugLogger.warn).not.toHaveBeenCalled(); + }); + + it('should log errors but continue if an agent fails to load', async () => { + const sourceDir = path.join(tempDir, 'source-ext-bad'); + createExtension({ + extensionsDir: sourceDir, + name: 'bad-agents-ext', + version: '1.0.0', + installMetadata: { + type: 'local', + source: path.join(sourceDir, 'bad-agents-ext'), + }, + }); + const extensionPath = path.join(sourceDir, 'bad-agents-ext'); + + const agentsDir = path.join(extensionPath, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + // Invalid agent (missing description) + fs.writeFileSync( + path.join(agentsDir, 'bad-agent.md'), + '---\nname: bad-agent\nkind: local\n---\nbody', + ); + + await extensionManager.loadExtensions(); + + const extension = await extensionManager.installOrUpdateExtension({ + type: 'local', + source: extensionPath, + }); + + expect(extension.name).toBe('bad-agents-ext'); + expect(extension.agents).toEqual([]); + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Error loading agent from bad-agents-ext'), + ); + }); +}); diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index d979692441..fafa801bf2 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -38,6 +38,7 @@ import { logExtensionUninstall, logExtensionUpdateEvent, loadSkillsFromDir, + loadAgentsFromDirectory, homedir, type ExtensionEvents, type MCPServerConfig, @@ -615,6 +616,17 @@ Would you like to attempt to install via "git clone" instead?`, path.join(effectiveExtensionPath, 'skills'), ); + const agentLoadResult = await loadAgentsFromDirectory( + path.join(effectiveExtensionPath, 'agents'), + ); + + // Log errors but don't fail the entire extension load + for (const error of agentLoadResult.errors) { + debugLogger.warn( + `[ExtensionManager] Error loading agent from ${config.name}: ${error.message}`, + ); + } + const extension: GeminiCLIExtension = { name: config.name, version: config.version, @@ -632,6 +644,7 @@ Would you like to attempt to install via "git clone" instead?`, settings: config.settings, resolvedSettings, skills, + agents: agentLoadResult.agents, }; this.loadedExtensions = [...this.loadedExtensions, extension]; diff --git a/packages/core/src/agents/registry.test.ts b/packages/core/src/agents/registry.test.ts index 42a6aab25b..95d0f925eb 100644 --- a/packages/core/src/agents/registry.test.ts +++ b/packages/core/src/agents/registry.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { AgentRegistry, getModelConfigAlias } from './registry.js'; import { makeFakeConfig } from '../test-utils/config.js'; import type { AgentDefinition, LocalAgentDefinition } from './types.js'; -import type { Config } from '../config/config.js'; +import type { Config, GeminiCLIExtension } from '../config/config.js'; import { debugLogger } from '../utils/debugLogger.js'; import { coreEvents, CoreEvent } from '../utils/events.js'; import { A2AClientManager } from './a2a-client-manager.js'; @@ -20,6 +20,7 @@ import { PREVIEW_GEMINI_MODEL_AUTO, } from '../config/models.js'; import * as tomlLoader from './agentLoader.js'; +import { SimpleExtensionLoader } from '../utils/extensionLoader.js'; vi.mock('./agentLoader.js', () => ({ loadAgentsFromDirectory: vi @@ -230,7 +231,7 @@ describe('AgentRegistry', () => { expect(registry.getDefinition('cli_help')).toBeDefined(); }); - it('should NOT register CLI help agent if disabled', async () => { + it('should register CLI help agent if disabled', async () => { const config = makeFakeConfig({ cliHelpAgentSettings: { enabled: false }, }); @@ -240,6 +241,59 @@ describe('AgentRegistry', () => { expect(registry.getDefinition('cli_help')).toBeUndefined(); }); + + it('should load agents from active extensions', async () => { + const extensionAgent = { + ...MOCK_AGENT_V1, + name: 'extension-agent', + }; + const extensions: GeminiCLIExtension[] = [ + { + name: 'test-extension', + isActive: true, + agents: [extensionAgent], + version: '1.0.0', + path: '/path/to/extension', + contextFiles: [], + id: 'test-extension-id', + }, + ]; + const mockConfig = makeFakeConfig({ + extensionLoader: new SimpleExtensionLoader(extensions), + enableAgents: true, + }); + const registry = new TestableAgentRegistry(mockConfig); + + await registry.initialize(); + + expect(registry.getDefinition('extension-agent')).toEqual(extensionAgent); + }); + + it('should NOT load agents from inactive extensions', async () => { + const extensionAgent = { + ...MOCK_AGENT_V1, + name: 'extension-agent', + }; + const extensions: GeminiCLIExtension[] = [ + { + name: 'test-extension', + isActive: false, + agents: [extensionAgent], + version: '1.0.0', + path: '/path/to/extension', + contextFiles: [], + id: 'test-extension-id', + }, + ]; + const mockConfig = makeFakeConfig({ + extensionLoader: new SimpleExtensionLoader(extensions), + }); + const registry = new TestableAgentRegistry(mockConfig); + + await registry.initialize(); + + expect(registry.getDefinition('extension-agent')).toBeUndefined(); + }); }); describe('registration logic', () => { diff --git a/packages/core/src/agents/registry.ts b/packages/core/src/agents/registry.ts index 71cb1442cc..cd3065e0f6 100644 --- a/packages/core/src/agents/registry.ts +++ b/packages/core/src/agents/registry.ts @@ -14,6 +14,7 @@ import { CliHelpAgent } from './cli-help-agent.js'; import { A2AClientManager } from './a2a-client-manager.js'; import { ADCHandler } from './remote-invocation.js'; import { type z } from 'zod'; +import type { GenerateContentConfig } from '@google/genai'; import { debugLogger } from '../utils/debugLogger.js'; import { DEFAULT_GEMINI_MODEL, @@ -120,6 +121,15 @@ export class AgentRegistry { ); } + // Load agents from extensions + for (const extension of this.config.getExtensions()) { + if (extension.isActive && extension.agents) { + await Promise.allSettled( + extension.agents.map((agent) => this.registerAgent(agent)), + ); + } + } + if (this.config.getDebugMode()) { debugLogger.log( `[AgentRegistry] Loaded with ${this.agents.size} agents.`, @@ -233,7 +243,7 @@ export class AgentRegistry { model = this.config.getModel(); } - const generateContentConfig = { + const generateContentConfig: GenerateContentConfig = { temperature: modelConfig.temp, topP: modelConfig.top_p, thinkingConfig: { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 6c57be29ab..02d73b1b6b 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -101,6 +101,7 @@ import { ExperimentFlags } from '../code_assist/experiments/flagNames.js'; import { debugLogger } from '../utils/debugLogger.js'; import { SkillManager, type SkillDefinition } from '../skills/skillManager.js'; import { startupProfiler } from '../telemetry/startupProfiler.js'; +import type { AgentDefinition } from '../agents/types.js'; export interface AccessibilitySettings { disableLoadingPhrases?: boolean; @@ -178,6 +179,7 @@ export interface GeminiCLIExtension { settings?: ExtensionSetting[]; resolvedSettings?: ResolvedExtensionSetting[]; skills?: SkillDefinition[]; + agents?: AgentDefinition[]; } export interface ExtensionInstallMetadata { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d587d3f221..a42ea862f2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -126,6 +126,7 @@ export * from './prompts/mcp-prompts.js'; // Export agent definitions export * from './agents/types.js'; +export * from './agents/agentLoader.js'; // Export specific tool logic export * from './tools/read-file.js';