From 92e31e3c4aede4a84f29ea31b372f29dbd55b67e Mon Sep 17 00:00:00 2001 From: joshualitt Date: Tue, 13 Jan 2026 12:16:02 -0800 Subject: [PATCH] feat(core, cli): Add support for agents in settings.json. (#16433) --- docs/get-started/configuration.md | 8 ++ packages/cli/src/config/config.ts | 1 + packages/cli/src/config/settingsSchema.ts | 56 ++++++++ packages/core/src/agents/registry.test.ts | 121 ++++++++++++++++++ packages/core/src/agents/registry.ts | 82 +++++++++--- packages/core/src/config/config.ts | 27 +++- .../core/src/services/modelConfigService.ts | 93 ++++++++------ schemas/settings.schema.json | 50 ++++++++ 8 files changed, 382 insertions(+), 56 deletions(-) diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 810468e406..93bcefa778 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -557,6 +557,14 @@ their corresponding top-level category object in your `settings.json` file. used. - **Default:** `[]` +#### `agents` + +- **`agents.overrides`** (object): + - **Description:** Override settings for specific agents, e.g. to disable the + agent, set a custom model config, or run config. + - **Default:** `{}` + - **Requires restart:** Yes + #### `context` - **`context.fileName`** (string | string[]): diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 4d353b360c..c10fd2518e 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -660,6 +660,7 @@ export async function loadCliConfig( mcpServers: mcpEnabled ? settings.mcpServers : {}, mcpEnabled, extensionsEnabled, + agents: settings.agents, allowedMcpServers: mcpEnabled ? (argv.allowedMcpServerNames ?? settings.mcp?.allowed) : undefined, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index c4e7cc7faa..c48e49cf01 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -785,6 +785,32 @@ const SETTINGS_SCHEMA = { }, }, + agents: { + type: 'object', + label: 'Agents', + category: 'Advanced', + requiresRestart: true, + default: {}, + description: 'Settings for subagents.', + showInDialog: false, + properties: { + overrides: { + type: 'object', + label: 'Agent Overrides', + category: 'Advanced', + requiresRestart: true, + default: {}, + description: + 'Override settings for specific agents, e.g. to disable the agent, set a custom model config, or run config.', + showInDialog: false, + additionalProperties: { + type: 'object', + ref: 'AgentOverride', + }, + }, + }, + }, + context: { type: 'object', label: 'Context', @@ -2002,6 +2028,36 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record< }, }, }, + AgentOverride: { + type: 'object', + description: 'Override settings for a specific agent.', + additionalProperties: false, + properties: { + modelConfig: { + type: 'object', + additionalProperties: true, + }, + runConfig: { + type: 'object', + description: 'Run configuration for an agent.', + additionalProperties: false, + properties: { + maxTimeMinutes: { + type: 'number', + description: 'The maximum execution time for the agent in minutes.', + }, + maxTurns: { + type: 'number', + description: 'The maximum number of conversational turns.', + }, + }, + }, + disabled: { + type: 'boolean', + description: 'Whether to disable the agent.', + }, + }, + }, CustomTheme: { type: 'object', description: diff --git a/packages/core/src/agents/registry.test.ts b/packages/core/src/agents/registry.test.ts index 95d0f925eb..837d4c5f63 100644 --- a/packages/core/src/agents/registry.test.ts +++ b/packages/core/src/agents/registry.test.ts @@ -619,6 +619,127 @@ describe('AgentRegistry', () => { ); }); }); + + describe('overrides', () => { + it('should skip registration if agent is disabled in settings', async () => { + const config = makeFakeConfig({ + agents: { + overrides: { + MockAgent: { disabled: true }, + }, + }, + }); + const registry = new TestableAgentRegistry(config); + + await registry.testRegisterAgent(MOCK_AGENT_V1); + + expect(registry.getDefinition('MockAgent')).toBeUndefined(); + }); + + it('should skip remote agent registration if disabled in settings', async () => { + const config = makeFakeConfig({ + agents: { + overrides: { + RemoteAgent: { disabled: true }, + }, + }, + }); + const registry = new TestableAgentRegistry(config); + + const remoteAgent: AgentDefinition = { + kind: 'remote', + name: 'RemoteAgent', + description: 'A remote agent', + agentCardUrl: 'https://example.com/card', + inputConfig: { inputs: {} }, + }; + + await registry.testRegisterAgent(remoteAgent); + + expect(registry.getDefinition('RemoteAgent')).toBeUndefined(); + }); + + it('should merge runConfig overrides', async () => { + const config = makeFakeConfig({ + agents: { + overrides: { + MockAgent: { + runConfig: { maxTurns: 50 }, + }, + }, + }, + }); + const registry = new TestableAgentRegistry(config); + + await registry.testRegisterAgent(MOCK_AGENT_V1); + + const def = registry.getDefinition('MockAgent') as LocalAgentDefinition; + expect(def.runConfig.max_turns).toBe(50); + expect(def.runConfig.max_time_minutes).toBe( + MOCK_AGENT_V1.runConfig.max_time_minutes, + ); + }); + + it('should apply modelConfig overrides', async () => { + const config = makeFakeConfig({ + agents: { + overrides: { + MockAgent: { + modelConfig: { + model: 'overridden-model', + generateContentConfig: { + temperature: 0.5, + }, + }, + }, + }, + }, + }); + const registry = new TestableAgentRegistry(config); + + await registry.testRegisterAgent(MOCK_AGENT_V1); + + const resolved = config.modelConfigService.getResolvedConfig({ + model: getModelConfigAlias(MOCK_AGENT_V1), + }); + + expect(resolved.model).toBe('overridden-model'); + expect(resolved.generateContentConfig.temperature).toBe(0.5); + // topP should still be MOCK_AGENT_V1.modelConfig.top_p (1) because we merged + expect(resolved.generateContentConfig.topP).toBe(1); + }); + + it('should deep merge generateContentConfig (e.g. thinkingConfig)', async () => { + const config = makeFakeConfig({ + agents: { + overrides: { + MockAgent: { + modelConfig: { + generateContentConfig: { + thinkingConfig: { + thinkingBudget: 16384, + }, + }, + }, + }, + }, + }, + }); + const registry = new TestableAgentRegistry(config); + + await registry.testRegisterAgent(MOCK_AGENT_V1); + + const resolved = config.modelConfigService.getResolvedConfig({ + model: getModelConfigAlias(MOCK_AGENT_V1), + }); + + expect(resolved.generateContentConfig.thinkingConfig).toEqual({ + includeThoughts: true, // Preserved from default + thinkingBudget: 16384, // Overridden + }); + }); + }); + describe('getToolDescription', () => { it('should return default message when no agents are registered', () => { expect(registry.getToolDescription()).toContain( diff --git a/packages/core/src/agents/registry.ts b/packages/core/src/agents/registry.ts index cd3065e0f6..0038a2b783 100644 --- a/packages/core/src/agents/registry.ts +++ b/packages/core/src/agents/registry.ts @@ -14,7 +14,6 @@ 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, @@ -23,6 +22,10 @@ import { isPreviewModel, isAutoModel, } from '../config/models.js'; +import { + type ModelConfig, + ModelConfigService, +} from '../services/modelConfigService.js'; /** * Returns the model config alias for a given agent definition. @@ -226,49 +229,83 @@ export class AgentRegistry { return; } + const overrides = + this.config.getAgentsSettings().overrides?.[definition.name]; + if (overrides?.disabled) { + if (this.config.getDebugMode()) { + debugLogger.log( + `[AgentRegistry] Skipping disabled agent '${definition.name}'`, + ); + } + return; + } + if (this.agents.has(definition.name) && this.config.getDebugMode()) { debugLogger.log(`[AgentRegistry] Overriding agent '${definition.name}'`); } - this.agents.set(definition.name, definition); + // TODO(16443): Refactor definition merging logic into a helper. + // To do this, we need to align the definition of the internal `Definition` + // type with the one exported in settings.json. + const mergedDefinition = { + ...definition, + runConfig: { + ...definition.runConfig, + max_time_minutes: + overrides?.runConfig?.maxTimeMinutes ?? + definition.runConfig.max_time_minutes, + max_turns: + overrides?.runConfig?.maxTurns ?? definition.runConfig.max_turns, + }, + }; + + this.agents.set(mergedDefinition.name, mergedDefinition); // Register model config. We always create a runtime alias. However, // if the user is using `auto` as a model string then we also create // runtime overrides to ensure the subagent generation settings are // respected regardless of the final model string from routing. // TODO(12916): Migrate sub-agents where possible to static configs. - const modelConfig = definition.modelConfig; + const modelConfig = mergedDefinition.modelConfig; let model = modelConfig.model; if (model === 'inherit') { model = this.config.getModel(); } - const generateContentConfig: GenerateContentConfig = { - temperature: modelConfig.temp, - topP: modelConfig.top_p, - thinkingConfig: { - includeThoughts: true, - thinkingBudget: modelConfig.thinkingBudget ?? -1, + let agentModelConfig: ModelConfig = { + model, + generateContentConfig: { + temperature: modelConfig.temp, + topP: modelConfig.top_p, + thinkingConfig: { + includeThoughts: true, + thinkingBudget: modelConfig.thinkingBudget ?? -1, + }, }, }; + // Apply standardized modelConfig overrides if present. + if (overrides?.modelConfig) { + agentModelConfig = ModelConfigService.merge( + agentModelConfig, + overrides.modelConfig, + ); + } + this.config.modelConfigService.registerRuntimeModelConfig( - getModelConfigAlias(definition), + getModelConfigAlias(mergedDefinition), { - modelConfig: { - model, - generateContentConfig, - }, + modelConfig: agentModelConfig, }, ); - if (isAutoModel(model)) { + if (agentModelConfig.model && isAutoModel(agentModelConfig.model)) { this.config.modelConfigService.registerRuntimeModelOverride({ match: { - overrideScope: definition.name, + overrideScope: mergedDefinition.name, }, modelConfig: { - generateContentConfig, + generateContentConfig: agentModelConfig.generateContentConfig, }, }); } @@ -292,6 +329,17 @@ export class AgentRegistry { return; } + const overrides = + this.config.getAgentsSettings().overrides?.[definition.name]; + if (overrides?.disabled) { + if (this.config.getDebugMode()) { + debugLogger.log( + `[AgentRegistry] Skipping disabled remote agent '${definition.name}'`, + ); + } + return; + } + if (this.agents.has(definition.name) && this.config.getDebugMode()) { debugLogger.log(`[AgentRegistry] Overriding agent '${definition.name}'`); } diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 02d73b1b6b..6dce2f7403 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -69,7 +69,10 @@ import type { FallbackModelHandler } from '../fallback/types.js'; import { ModelAvailabilityService } from '../availability/modelAvailabilityService.js'; import { ModelRouterService } from '../routing/modelRouterService.js'; import { OutputFormat } from '../output/types.js'; -import type { ModelConfigServiceConfig } from '../services/modelConfigService.js'; +import type { + ModelConfig, + ModelConfigServiceConfig, +} from '../services/modelConfigService.js'; import { ModelConfigService } from '../services/modelConfigService.js'; import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js'; import { ContextManager } from '../services/contextManager.js'; @@ -159,6 +162,21 @@ export interface CliHelpAgentSettings { enabled?: boolean; } +export interface AgentRunConfig { + maxTimeMinutes?: number; + maxTurns?: number; +} + +export interface AgentOverride { + modelConfig?: ModelConfig; + runConfig?: AgentRunConfig; + disabled?: boolean; +} + +export interface AgentSettings { + overrides?: Record; +} + /** * All information required in CLI to handle an extension. Defined in Core so * that the collection of loaded, active, and inactive extensions can be passed @@ -364,6 +382,7 @@ export interface ConfigParameters { onModelChange?: (model: string) => void; mcpEnabled?: boolean; extensionsEnabled?: boolean; + agents?: AgentSettings; onReload?: () => Promise<{ disabledSkills?: string[] }>; } @@ -496,6 +515,7 @@ export class Config { | undefined; private readonly enableAgents: boolean; + private readonly agents: AgentSettings; private readonly skillsSupport: boolean; private disabledSkills: string[]; @@ -570,6 +590,7 @@ export class Config { this.model = params.model; this._activeModel = params.model; this.enableAgents = params.enableAgents ?? false; + this.agents = params.agents ?? {}; this.disableLLMCorrection = params.disableLLMCorrection ?? false; this.skillsSupport = params.skillsSupport ?? false; this.disabledSkills = params.disabledSkills ?? []; @@ -1443,6 +1464,10 @@ export class Config { return this.noBrowser; } + getAgentsSettings(): AgentSettings { + return this.agents; + } + isBrowserLaunchSuppressed(): boolean { return this.getNoBrowser() || !shouldAttemptBrowserLaunch(); } diff --git a/packages/core/src/services/modelConfigService.ts b/packages/core/src/services/modelConfigService.ts index 0ec6d77ffb..a73764e75a 100644 --- a/packages/core/src/services/modelConfigService.ts +++ b/packages/core/src/services/modelConfigService.ts @@ -119,14 +119,10 @@ export class ModelConfigService { ...this.runtimeAliases, }; - const { - aliasChain, - baseModel: initialBaseModel, - resolvedConfig: initialResolvedConfig, - } = this.resolveAliasChain(context.model, allAliases); - - let baseModel = initialBaseModel; - let resolvedConfig = initialResolvedConfig; + const { aliasChain, baseModel, resolvedConfig } = this.resolveAliasChain( + context.model, + allAliases, + ); const modelToLevel = this.buildModelLevelMap(aliasChain, baseModel); const allOverrides = [ @@ -142,19 +138,22 @@ export class ModelConfigService { this.sortOverrides(matches); + let currentConfig: ModelConfig = { + model: baseModel, + generateContentConfig: resolvedConfig, + }; + for (const match of matches) { - if (match.modelConfig.model) { - baseModel = match.modelConfig.model; - } - if (match.modelConfig.generateContentConfig) { - resolvedConfig = this.deepMerge( - resolvedConfig, - match.modelConfig.generateContentConfig, - ); - } + currentConfig = ModelConfigService.merge( + currentConfig, + match.modelConfig, + ); } - return { model: baseModel, generateContentConfig: resolvedConfig }; + return { + model: currentConfig.model, + generateContentConfig: currentConfig.generateContentConfig ?? {}, + }; } private resolveAliasChain( @@ -165,8 +164,6 @@ export class ModelConfigService { baseModel: string | undefined; resolvedConfig: GenerateContentConfig; } { - let baseModel: string | undefined = undefined; - let resolvedConfig: GenerateContentConfig = {}; const aliasChain: string[] = []; if (allAliases[requestedModel]) { @@ -194,17 +191,19 @@ export class ModelConfigService { // Root-to-Leaf chain for merging and level assignment. const reversedChain = [...aliasChain].reverse(); + let resolvedConfig: ModelConfig = {}; for (const aliasName of reversedChain) { const alias = allAliases[aliasName]; - if (alias.modelConfig.model) { - baseModel = alias.modelConfig.model; - } - resolvedConfig = this.deepMerge( + resolvedConfig = ModelConfigService.merge( resolvedConfig, - alias.modelConfig.generateContentConfig, + alias.modelConfig, ); } - return { aliasChain: reversedChain, baseModel, resolvedConfig }; + return { + aliasChain: reversedChain, + baseModel: resolvedConfig.model, + resolvedConfig: resolvedConfig.generateContentConfig ?? {}, + }; } return { @@ -298,21 +297,36 @@ export class ModelConfigService { } as ResolvedModelConfig; } - private isObject(item: unknown): item is Record { + static isObject(item: unknown): item is Record { return !!item && typeof item === 'object' && !Array.isArray(item); } - private deepMerge( - config1: GenerateContentConfig | undefined, - config2: GenerateContentConfig | undefined, - ): Record { - return this.genericDeepMerge( - config1 as Record | undefined, - config2 as Record | undefined, - ); + /** + * Merges an override `ModelConfig` into a base `ModelConfig`. + * The override's model name takes precedence if provided. + * The `generateContentConfig` properties are deeply merged. + */ + static merge(base: ModelConfig, override: ModelConfig): ModelConfig { + return { + model: override.model ?? base.model, + generateContentConfig: ModelConfigService.deepMerge( + base.generateContentConfig, + override.generateContentConfig, + ), + }; } - private genericDeepMerge( + static deepMerge( + config1: GenerateContentConfig | undefined, + config2: GenerateContentConfig | undefined, + ): GenerateContentConfig { + return ModelConfigService.genericDeepMerge( + config1 as Record | undefined, + config2 as Record | undefined, + ) as GenerateContentConfig; + } + + private static genericDeepMerge( ...objects: Array | undefined> ): Record { return objects.reduce((acc: Record, obj) => { @@ -329,8 +343,11 @@ export class ModelConfigService { // override the base array. // TODO(joshualitt): Consider knobs here, i.e. opt-in to deep merging // arrays on a case-by-case basis. - if (this.isObject(accValue) && this.isObject(objValue)) { - acc[key] = this.deepMerge(accValue, objValue); + if ( + ModelConfigService.isObject(accValue) && + ModelConfigService.isObject(objValue) + ) { + acc[key] = ModelConfigService.genericDeepMerge(accValue, objValue); } else { acc[key] = objValue; } diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 6c7f8beaa4..2c3effa172 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -926,6 +926,26 @@ }, "additionalProperties": false }, + "agents": { + "title": "Agents", + "description": "Settings for subagents.", + "markdownDescription": "Settings for subagents.\n\n- Category: `Advanced`\n- Requires restart: `yes`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "overrides": { + "title": "Agent Overrides", + "description": "Override settings for specific agents, e.g. to disable the agent, set a custom model config, or run config.", + "markdownDescription": "Override settings for specific agents, e.g. to disable the agent, set a custom model config, or run config.\n\n- Category: `Advanced`\n- Requires restart: `yes`\n- Default: `{}`", + "default": {}, + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/AgentOverride" + } + } + }, + "additionalProperties": false + }, "context": { "title": "Context", "description": "Settings for managing context provided to the model.", @@ -1854,6 +1874,36 @@ } } }, + "AgentOverride": { + "type": "object", + "description": "Override settings for a specific agent.", + "additionalProperties": false, + "properties": { + "modelConfig": { + "type": "object", + "additionalProperties": true + }, + "runConfig": { + "type": "object", + "description": "Run configuration for an agent.", + "additionalProperties": false, + "properties": { + "maxTimeMinutes": { + "type": "number", + "description": "The maximum execution time for the agent in minutes." + }, + "maxTurns": { + "type": "number", + "description": "The maximum number of conversational turns." + } + } + }, + "disabled": { + "type": "boolean", + "description": "Whether to disable the agent." + } + } + }, "CustomTheme": { "type": "object", "description": "Custom theme definition used for styling Gemini CLI output. Colors are provided as hex strings or named ANSI colors.",