mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
Implement support for subagents as extensions. (#16473)
This commit is contained in:
committed by
GitHub
parent
0f7a136612
commit
aa52462550
140
packages/cli/src/config/extension-manager-agents.test.ts
Normal file
140
packages/cli/src/config/extension-manager-agents.test.ts
Normal file
@@ -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<typeof import('node:os')>();
|
||||
return {
|
||||
...actual,
|
||||
homedir: mockHomedir,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock @google/gemini-cli-core
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user