feat(core): Add generalist agent. (#16638)

This commit is contained in:
joshualitt
2026-01-16 09:21:13 -08:00
committed by GitHub
parent ce35d84113
commit fcd860e1b0
9 changed files with 283 additions and 32 deletions

View File

@@ -2093,6 +2093,10 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
},
},
},
enabled: {
type: 'boolean',
description: 'Whether to enable the agent.',
},
disabled: {
type: 'boolean',
description: 'Whether to disable the agent.',

View File

@@ -0,0 +1,36 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { GeneralistAgent } from './generalist-agent.js';
import { makeFakeConfig } from '../test-utils/config.js';
import { DELEGATE_TO_AGENT_TOOL_NAME } from '../tools/tool-names.js';
import type { ToolRegistry } from '../tools/tool-registry.js';
import type { AgentRegistry } from './registry.js';
describe('GeneralistAgent', () => {
it('should create a valid generalist agent definition', () => {
const config = makeFakeConfig();
vi.spyOn(config, 'getToolRegistry').mockReturnValue({
getAllToolNames: () => ['tool1', 'tool2', DELEGATE_TO_AGENT_TOOL_NAME],
} as unknown as ToolRegistry);
vi.spyOn(config, 'getAgentRegistry').mockReturnValue({
getDirectoryContext: () => 'mock directory context',
} as unknown as AgentRegistry);
const agent = GeneralistAgent(config);
expect(agent.name).toBe('generalist');
expect(agent.kind).toBe('local');
expect(agent.modelConfig.model).toBe('inherit');
expect(agent.toolConfig?.tools).toBeDefined();
expect(agent.toolConfig?.tools).not.toContain(DELEGATE_TO_AGENT_TOOL_NAME);
expect(agent.toolConfig?.tools).toContain('tool1');
expect(agent.promptConfig.systemPrompt).toContain('CLI agent');
// Ensure it's non-interactive
expect(agent.promptConfig.systemPrompt).toContain('non-interactive');
});
});

View File

@@ -0,0 +1,71 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { z } from 'zod';
import type { Config } from '../config/config.js';
import { getCoreSystemPrompt } from '../core/prompts.js';
import type { LocalAgentDefinition } from './types.js';
import { DELEGATE_TO_AGENT_TOOL_NAME } from '../tools/tool-names.js';
const GeneralistAgentSchema = z.object({
response: z.string().describe('The final response from the agent.'),
});
/**
* A general-purpose AI agent with access to all tools.
* It uses the same core system prompt as the main agent but in a non-interactive mode.
*/
export const GeneralistAgent = (
config: Config,
): LocalAgentDefinition<typeof GeneralistAgentSchema> => ({
kind: 'local',
name: 'generalist',
displayName: 'Generalist Agent',
description:
"A general-purpose AI agent with access to all tools. Use it for complex tasks that don't fit into other specialized agents.",
experimental: true,
inputConfig: {
inputs: {
request: {
description: 'The task or question for the generalist agent.',
type: 'string',
required: true,
},
},
},
outputConfig: {
outputName: 'result',
description: 'The final answer or results of the task.',
schema: GeneralistAgentSchema,
},
modelConfig: {
model: 'inherit',
},
get toolConfig() {
// TODO(15179): Support recursive agent invocation.
const tools = config
.getToolRegistry()
.getAllToolNames()
.filter((name) => name !== DELEGATE_TO_AGENT_TOOL_NAME);
return {
tools,
};
},
get promptConfig() {
return {
systemPrompt: getCoreSystemPrompt(
config,
/*useMemory=*/ undefined,
/*interactiveOverride=*/ false,
),
query: '${request}',
};
},
runConfig: {
maxTimeMinutes: 10,
maxTurns: 20,
},
});

View File

@@ -21,6 +21,8 @@ import {
} from '../config/models.js';
import * as tomlLoader from './agentLoader.js';
import { SimpleExtensionLoader } from '../utils/extensionLoader.js';
import type { ConfigParameters } from '../config/config.js';
import type { ToolRegistry } from '../tools/tool-registry.js';
vi.mock('./agentLoader.js', () => ({
loadAgentsFromDirectory: vi
@@ -34,6 +36,17 @@ vi.mock('./a2a-client-manager.js', () => ({
},
}));
function makeMockedConfig(params?: Partial<ConfigParameters>): Config {
const config = makeFakeConfig(params);
vi.spyOn(config, 'getToolRegistry').mockReturnValue({
getAllToolNames: () => ['tool1', 'tool2'],
} as unknown as ToolRegistry);
vi.spyOn(config, 'getAgentRegistry').mockReturnValue({
getDirectoryContext: () => 'mock directory context',
} as unknown as AgentRegistry);
return config;
}
// A test-only subclass to expose the protected `registerAgent` method.
class TestableAgentRegistry extends AgentRegistry {
async testRegisterAgent(definition: AgentDefinition): Promise<void> {
@@ -73,7 +86,7 @@ describe('AgentRegistry', () => {
beforeEach(() => {
// Default configuration (debugMode: false)
mockConfig = makeFakeConfig();
mockConfig = makeMockedConfig();
registry = new TestableAgentRegistry(mockConfig);
vi.mocked(tomlLoader.loadAgentsFromDirectory).mockResolvedValue({
agents: [],
@@ -97,7 +110,7 @@ describe('AgentRegistry', () => {
// });
it('should log the count of loaded agents in debug mode', async () => {
const debugConfig = makeFakeConfig({
const debugConfig = makeMockedConfig({
debugMode: true,
enableAgents: true,
});
@@ -115,7 +128,7 @@ describe('AgentRegistry', () => {
});
it('should use preview flash model for codebase investigator if main model is preview pro', async () => {
const previewConfig = makeFakeConfig({
const previewConfig = makeMockedConfig({
model: PREVIEW_GEMINI_MODEL,
codebaseInvestigatorSettings: {
enabled: true,
@@ -136,7 +149,7 @@ describe('AgentRegistry', () => {
});
it('should use preview flash model for codebase investigator if main model is preview auto', async () => {
const previewConfig = makeFakeConfig({
const previewConfig = makeMockedConfig({
model: PREVIEW_GEMINI_MODEL_AUTO,
codebaseInvestigatorSettings: {
enabled: true,
@@ -157,7 +170,7 @@ describe('AgentRegistry', () => {
});
it('should use the model from the investigator settings', async () => {
const previewConfig = makeFakeConfig({
const previewConfig = makeMockedConfig({
model: PREVIEW_GEMINI_MODEL,
codebaseInvestigatorSettings: {
enabled: true,
@@ -178,7 +191,7 @@ describe('AgentRegistry', () => {
});
it('should load agents from user and project directories with correct precedence', async () => {
mockConfig = makeFakeConfig({ enableAgents: true });
mockConfig = makeMockedConfig({ enableAgents: true });
registry = new TestableAgentRegistry(mockConfig);
const userAgent = {
@@ -217,7 +230,7 @@ describe('AgentRegistry', () => {
});
it('should NOT load TOML agents when enableAgents is false', async () => {
const disabledConfig = makeFakeConfig({
const disabledConfig = makeMockedConfig({
enableAgents: false,
codebaseInvestigatorSettings: { enabled: false },
cliHelpAgentSettings: { enabled: false },
@@ -233,7 +246,7 @@ describe('AgentRegistry', () => {
});
it('should register CLI help agent by default', async () => {
const config = makeFakeConfig();
const config = makeMockedConfig();
const registry = new TestableAgentRegistry(config);
await registry.initialize();
@@ -242,7 +255,7 @@ describe('AgentRegistry', () => {
});
it('should register CLI help agent if disabled', async () => {
const config = makeFakeConfig({
const config = makeMockedConfig({
cliHelpAgentSettings: { enabled: false },
});
const registry = new TestableAgentRegistry(config);
@@ -252,6 +265,61 @@ describe('AgentRegistry', () => {
expect(registry.getDefinition('cli_help')).toBeUndefined();
});
it('should NOT register generalist agent by default (because it is experimental)', async () => {
const config = makeMockedConfig();
const registry = new TestableAgentRegistry(config);
await registry.initialize();
expect(registry.getDefinition('generalist')).toBeUndefined();
});
it('should register generalist agent if explicitly enabled via override', async () => {
const config = makeMockedConfig({
agents: {
overrides: {
generalist: { enabled: true },
},
},
});
const registry = new TestableAgentRegistry(config);
await registry.initialize();
expect(registry.getDefinition('generalist')).toBeDefined();
});
it('should NOT register a non-experimental agent if enabled is false', async () => {
// CLI help is NOT experimental, but we explicitly disable it via enabled: false
const config = makeMockedConfig({
agents: {
overrides: {
cli_help: { enabled: false },
},
},
});
const registry = new TestableAgentRegistry(config);
await registry.initialize();
expect(registry.getDefinition('cli_help')).toBeUndefined();
});
it('should respect disabled override over enabled override', async () => {
const config = makeMockedConfig({
agents: {
overrides: {
generalist: { enabled: true, disabled: true },
},
},
});
const registry = new TestableAgentRegistry(config);
await registry.initialize();
expect(registry.getDefinition('generalist')).toBeUndefined();
});
it('should load agents from active extensions', async () => {
const extensionAgent = {
...MOCK_AGENT_V1,
@@ -268,7 +336,7 @@ describe('AgentRegistry', () => {
id: 'test-extension-id',
},
];
const mockConfig = makeFakeConfig({
const mockConfig = makeMockedConfig({
extensionLoader: new SimpleExtensionLoader(extensions),
enableAgents: true,
});
@@ -295,7 +363,7 @@ describe('AgentRegistry', () => {
id: 'test-extension-id',
},
];
const mockConfig = makeFakeConfig({
const mockConfig = makeMockedConfig({
extensionLoader: new SimpleExtensionLoader(extensions),
});
const registry = new TestableAgentRegistry(mockConfig);
@@ -391,7 +459,7 @@ describe('AgentRegistry', () => {
});
it('should log remote agent registration in debug mode', async () => {
const debugConfig = makeFakeConfig({ debugMode: true });
const debugConfig = makeMockedConfig({ debugMode: true });
const debugRegistry = new TestableAgentRegistry(debugConfig);
const debugLogSpy = vi
.spyOn(debugLogger, 'log')
@@ -469,7 +537,7 @@ describe('AgentRegistry', () => {
});
it('should log overwrites when in debug mode', async () => {
const debugConfig = makeFakeConfig({ debugMode: true });
const debugConfig = makeMockedConfig({ debugMode: true });
const debugRegistry = new TestableAgentRegistry(debugConfig);
const debugLogSpy = vi
.spyOn(debugLogger, 'log')
@@ -511,7 +579,7 @@ describe('AgentRegistry', () => {
describe('reload', () => {
it('should clear existing agents and reload from directories', async () => {
const config = makeFakeConfig({ enableAgents: true });
const config = makeMockedConfig({ enableAgents: true });
const registry = new TestableAgentRegistry(config);
const initialAgent = { ...MOCK_AGENT_V1, name: 'InitialAgent' };
@@ -542,7 +610,7 @@ describe('AgentRegistry', () => {
describe('inheritance and refresh', () => {
it('should resolve "inherit" to the current model from configuration', async () => {
const config = makeFakeConfig({ model: 'current-model' });
const config = makeMockedConfig({ model: 'current-model' });
const registry = new TestableAgentRegistry(config);
const agent: AgentDefinition = {
@@ -559,7 +627,7 @@ describe('AgentRegistry', () => {
});
it('should update inherited models when the main model changes', async () => {
const config = makeFakeConfig({ model: 'initial-model' });
const config = makeMockedConfig({ model: 'initial-model' });
const registry = new TestableAgentRegistry(config);
await registry.initialize();
@@ -633,7 +701,7 @@ describe('AgentRegistry', () => {
describe('overrides', () => {
it('should skip registration if agent is disabled in settings', async () => {
const config = makeFakeConfig({
const config = makeMockedConfig({
agents: {
overrides: {
MockAgent: { disabled: true },
@@ -648,7 +716,7 @@ describe('AgentRegistry', () => {
});
it('should skip remote agent registration if disabled in settings', async () => {
const config = makeFakeConfig({
const config = makeMockedConfig({
agents: {
overrides: {
RemoteAgent: { disabled: true },
@@ -671,7 +739,7 @@ describe('AgentRegistry', () => {
});
it('should merge runConfig overrides', async () => {
const config = makeFakeConfig({
const config = makeMockedConfig({
agents: {
overrides: {
MockAgent: {
@@ -692,7 +760,7 @@ describe('AgentRegistry', () => {
});
it('should apply modelConfig overrides', async () => {
const config = makeFakeConfig({
const config = makeMockedConfig({
agents: {
overrides: {
MockAgent: {
@@ -721,7 +789,7 @@ describe('AgentRegistry', () => {
});
it('should deep merge generateContentConfig (e.g. thinkingConfig)', async () => {
const config = makeFakeConfig({
const config = makeMockedConfig({
agents: {
overrides: {
MockAgent: {
@@ -749,6 +817,40 @@ describe('AgentRegistry', () => {
thinkingBudget: 16384, // Overridden
});
});
it('should preserve lazy getters when applying overrides', async () => {
let getterCalled = false;
const agentWithGetter: LocalAgentDefinition = {
...MOCK_AGENT_V1,
name: 'GetterAgent',
get toolConfig() {
getterCalled = true;
return { tools: ['lazy-tool'] };
},
};
const config = makeMockedConfig({
agents: {
overrides: {
GetterAgent: {
runConfig: { maxTurns: 100 },
},
},
},
});
const registry = new TestableAgentRegistry(config);
await registry.testRegisterAgent(agentWithGetter);
const registeredDef = registry.getDefinition(
'GetterAgent',
) as LocalAgentDefinition;
expect(registeredDef.runConfig.maxTurns).toBe(100);
expect(getterCalled).toBe(false); // Getter should not have been called yet
expect(registeredDef.toolConfig?.tools).toEqual(['lazy-tool']);
expect(getterCalled).toBe(true); // Getter should have been called now
});
});
describe('getToolDescription', () => {

View File

@@ -11,6 +11,7 @@ import type { AgentDefinition, LocalAgentDefinition } from './types.js';
import { loadAgentsFromDirectory } from './agentLoader.js';
import { CodebaseInvestigatorAgent } from './codebase-investigator.js';
import { CliHelpAgent } from './cli-help-agent.js';
import { GeneralistAgent } from './generalist-agent.js';
import { A2AClientManager } from './a2a-client-manager.js';
import { ADCHandler } from './remote-invocation.js';
import { type z } from 'zod';
@@ -203,6 +204,9 @@ export class AgentRegistry {
) {
this.registerLocalAgent(CliHelpAgent(this.config));
}
// Register the generalist agent.
this.registerLocalAgent(GeneralistAgent(this.config));
}
private async refreshAgents(): Promise<void> {
@@ -249,7 +253,8 @@ export class AgentRegistry {
const settingsOverrides =
this.config.getAgentsSettings().overrides?.[definition.name];
if (settingsOverrides?.disabled) {
if (!this.isAgentEnabled(definition, settingsOverrides)) {
if (this.config.getDebugMode()) {
debugLogger.log(
`[AgentRegistry] Skipping disabled agent '${definition.name}'`,
@@ -268,6 +273,24 @@ export class AgentRegistry {
this.registerModelConfigs(mergedDefinition);
}
private isAgentEnabled<TOutput extends z.ZodTypeAny>(
definition: AgentDefinition<TOutput>,
overrides?: AgentOverride,
): boolean {
const isExperimental = definition.experimental === true;
let isEnabled = !isExperimental;
if (overrides) {
if (overrides.disabled !== undefined) {
isEnabled = !overrides.disabled;
} else if (overrides.enabled !== undefined) {
isEnabled = overrides.enabled;
}
}
return isEnabled;
}
/**
* Registers a remote agent definition asynchronously.
*/
@@ -288,7 +311,8 @@ export class AgentRegistry {
const overrides =
this.config.getAgentsSettings().overrides?.[definition.name];
if (overrides?.disabled) {
if (!this.isAgentEnabled(definition, overrides)) {
if (this.config.getDebugMode()) {
debugLogger.log(
`[AgentRegistry] Skipping disabled remote agent '${definition.name}'`,
@@ -341,17 +365,24 @@ export class AgentRegistry {
return definition;
}
return {
...definition,
runConfig: {
// Use Object.create to preserve lazy getters on the definition object
const merged: LocalAgentDefinition<TOutput> = Object.create(definition);
if (overrides.runConfig) {
merged.runConfig = {
...definition.runConfig,
...overrides.runConfig,
},
modelConfig: ModelConfigService.merge(
};
}
if (overrides.modelConfig) {
merged.modelConfig = ModelConfigService.merge(
definition.modelConfig,
overrides.modelConfig ?? {},
),
};
overrides.modelConfig,
);
}
return merged;
}
private registerModelConfigs<TOutput extends z.ZodTypeAny>(

View File

@@ -65,6 +65,7 @@ export interface BaseAgentDefinition<
name: string;
displayName?: string;
description: string;
experimental?: boolean;
inputConfig: InputConfig;
outputConfig?: OutputConfig<TOutput>;
}

View File

@@ -171,6 +171,7 @@ export interface AgentOverride {
modelConfig?: ModelConfig;
runConfig?: AgentRunConfig;
disabled?: boolean;
enabled?: boolean;
}
export interface AgentSettings {

View File

@@ -80,6 +80,7 @@ export function resolvePathFromEnv(envVar?: string): {
export function getCoreSystemPrompt(
config: Config,
userMemory?: string,
interactiveOverride?: boolean,
): string {
// A flag to indicate whether the system prompt override is active.
let systemMdEnabled = false;
@@ -128,7 +129,7 @@ export function getCoreSystemPrompt(
.getAllToolNames()
.includes(WriteTodosTool.Name);
const interactiveMode = config.isInteractive();
const interactiveMode = interactiveOverride ?? config.isInteractive();
const skills = config.getSkillManager().getSkills();
let skillsPrompt = '';

View File

@@ -1929,6 +1929,10 @@
}
}
},
"enabled": {
"type": "boolean",
"description": "Whether to enable the agent."
},
"disabled": {
"type": "boolean",
"description": "Whether to disable the agent."