From fd14ce22e33223a50b009b8c8923f13162e7cfbe Mon Sep 17 00:00:00 2001 From: joshualitt Date: Fri, 16 Jan 2026 09:21:13 -0800 Subject: [PATCH] feat(core): Add `generalist` agent. (#16638) --- packages/cli/src/config/settingsSchema.ts | 4 + .../core/src/agents/generalist-agent.test.ts | 36 +++++ packages/core/src/agents/generalist-agent.ts | 71 +++++++++ packages/core/src/agents/registry.test.ts | 144 +++++++++++++++--- packages/core/src/agents/registry.ts | 51 +++++-- packages/core/src/agents/types.ts | 1 + packages/core/src/config/config.ts | 1 + packages/core/src/core/prompts.ts | 3 +- schemas/settings.schema.json | 4 + 9 files changed, 283 insertions(+), 32 deletions(-) create mode 100644 packages/core/src/agents/generalist-agent.test.ts create mode 100644 packages/core/src/agents/generalist-agent.ts diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 0af37b7c1f..a9c49ce581 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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.', diff --git a/packages/core/src/agents/generalist-agent.test.ts b/packages/core/src/agents/generalist-agent.test.ts new file mode 100644 index 0000000000..efd651c121 --- /dev/null +++ b/packages/core/src/agents/generalist-agent.test.ts @@ -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'); + }); +}); diff --git a/packages/core/src/agents/generalist-agent.ts b/packages/core/src/agents/generalist-agent.ts new file mode 100644 index 0000000000..c9c3340a0c --- /dev/null +++ b/packages/core/src/agents/generalist-agent.ts @@ -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 => ({ + 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, + }, +}); diff --git a/packages/core/src/agents/registry.test.ts b/packages/core/src/agents/registry.test.ts index a81b3035bb..b35a62ea9b 100644 --- a/packages/core/src/agents/registry.test.ts +++ b/packages/core/src/agents/registry.test.ts @@ -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): 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 { @@ -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', () => { diff --git a/packages/core/src/agents/registry.ts b/packages/core/src/agents/registry.ts index dd7a7d04fd..82fccb05cc 100644 --- a/packages/core/src/agents/registry.ts +++ b/packages/core/src/agents/registry.ts @@ -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 { @@ -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( + definition: AgentDefinition, + 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 = 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( diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts index 23c6f75626..a1811c0f15 100644 --- a/packages/core/src/agents/types.ts +++ b/packages/core/src/agents/types.ts @@ -65,6 +65,7 @@ export interface BaseAgentDefinition< name: string; displayName?: string; description: string; + experimental?: boolean; inputConfig: InputConfig; outputConfig?: OutputConfig; } diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 20e1e20f7b..34a02a849d 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -171,6 +171,7 @@ export interface AgentOverride { modelConfig?: ModelConfig; runConfig?: AgentRunConfig; disabled?: boolean; + enabled?: boolean; } export interface AgentSettings { diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index 0aec32f155..17bf1cd64d 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -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 = ''; diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 4b99ec4632..7c36fdf589 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -1929,6 +1929,10 @@ } } }, + "enabled": { + "type": "boolean", + "description": "Whether to enable the agent." + }, "disabled": { "type": "boolean", "description": "Whether to disable the agent."