feat(core, cli): Add support for agents in settings.json. (#16433)

This commit is contained in:
joshualitt
2026-01-13 12:16:02 -08:00
committed by GitHub
parent e931ebe581
commit 92e31e3c4a
8 changed files with 382 additions and 56 deletions
+8
View File
@@ -557,6 +557,14 @@ their corresponding top-level category object in your `settings.json` file.
used. used.
- **Default:** `[]` - **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`
- **`context.fileName`** (string | string[]): - **`context.fileName`** (string | string[]):
+1
View File
@@ -660,6 +660,7 @@ export async function loadCliConfig(
mcpServers: mcpEnabled ? settings.mcpServers : {}, mcpServers: mcpEnabled ? settings.mcpServers : {},
mcpEnabled, mcpEnabled,
extensionsEnabled, extensionsEnabled,
agents: settings.agents,
allowedMcpServers: mcpEnabled allowedMcpServers: mcpEnabled
? (argv.allowedMcpServerNames ?? settings.mcp?.allowed) ? (argv.allowedMcpServerNames ?? settings.mcp?.allowed)
: undefined, : undefined,
+56
View File
@@ -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: { context: {
type: 'object', type: 'object',
label: 'Context', 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: { CustomTheme: {
type: 'object', type: 'object',
description: description:
+121
View File
@@ -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', () => { describe('getToolDescription', () => {
it('should return default message when no agents are registered', () => { it('should return default message when no agents are registered', () => {
expect(registry.getToolDescription()).toContain( expect(registry.getToolDescription()).toContain(
+65 -17
View File
@@ -14,7 +14,6 @@ import { CliHelpAgent } from './cli-help-agent.js';
import { A2AClientManager } from './a2a-client-manager.js'; import { A2AClientManager } from './a2a-client-manager.js';
import { ADCHandler } from './remote-invocation.js'; import { ADCHandler } from './remote-invocation.js';
import { type z } from 'zod'; import { type z } from 'zod';
import type { GenerateContentConfig } from '@google/genai';
import { debugLogger } from '../utils/debugLogger.js'; import { debugLogger } from '../utils/debugLogger.js';
import { import {
DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_MODEL,
@@ -23,6 +22,10 @@ import {
isPreviewModel, isPreviewModel,
isAutoModel, isAutoModel,
} from '../config/models.js'; } from '../config/models.js';
import {
type ModelConfig,
ModelConfigService,
} from '../services/modelConfigService.js';
/** /**
* Returns the model config alias for a given agent definition. * Returns the model config alias for a given agent definition.
@@ -226,49 +229,83 @@ export class AgentRegistry {
return; 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()) { if (this.agents.has(definition.name) && this.config.getDebugMode()) {
debugLogger.log(`[AgentRegistry] Overriding agent '${definition.name}'`); 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, // Register model config. We always create a runtime alias. However,
// if the user is using `auto` as a model string then we also create // if the user is using `auto` as a model string then we also create
// runtime overrides to ensure the subagent generation settings are // runtime overrides to ensure the subagent generation settings are
// respected regardless of the final model string from routing. // respected regardless of the final model string from routing.
// TODO(12916): Migrate sub-agents where possible to static configs. // TODO(12916): Migrate sub-agents where possible to static configs.
const modelConfig = definition.modelConfig; const modelConfig = mergedDefinition.modelConfig;
let model = modelConfig.model; let model = modelConfig.model;
if (model === 'inherit') { if (model === 'inherit') {
model = this.config.getModel(); model = this.config.getModel();
} }
const generateContentConfig: GenerateContentConfig = { let agentModelConfig: ModelConfig = {
temperature: modelConfig.temp, model,
topP: modelConfig.top_p, generateContentConfig: {
thinkingConfig: { temperature: modelConfig.temp,
includeThoughts: true, topP: modelConfig.top_p,
thinkingBudget: modelConfig.thinkingBudget ?? -1, 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( this.config.modelConfigService.registerRuntimeModelConfig(
getModelConfigAlias(definition), getModelConfigAlias(mergedDefinition),
{ {
modelConfig: { modelConfig: agentModelConfig,
model,
generateContentConfig,
},
}, },
); );
if (isAutoModel(model)) { if (agentModelConfig.model && isAutoModel(agentModelConfig.model)) {
this.config.modelConfigService.registerRuntimeModelOverride({ this.config.modelConfigService.registerRuntimeModelOverride({
match: { match: {
overrideScope: definition.name, overrideScope: mergedDefinition.name,
}, },
modelConfig: { modelConfig: {
generateContentConfig, generateContentConfig: agentModelConfig.generateContentConfig,
}, },
}); });
} }
@@ -292,6 +329,17 @@ export class AgentRegistry {
return; 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()) { if (this.agents.has(definition.name) && this.config.getDebugMode()) {
debugLogger.log(`[AgentRegistry] Overriding agent '${definition.name}'`); debugLogger.log(`[AgentRegistry] Overriding agent '${definition.name}'`);
} }
+26 -1
View File
@@ -69,7 +69,10 @@ import type { FallbackModelHandler } from '../fallback/types.js';
import { ModelAvailabilityService } from '../availability/modelAvailabilityService.js'; import { ModelAvailabilityService } from '../availability/modelAvailabilityService.js';
import { ModelRouterService } from '../routing/modelRouterService.js'; import { ModelRouterService } from '../routing/modelRouterService.js';
import { OutputFormat } from '../output/types.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 { ModelConfigService } from '../services/modelConfigService.js';
import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js'; import { DEFAULT_MODEL_CONFIGS } from './defaultModelConfigs.js';
import { ContextManager } from '../services/contextManager.js'; import { ContextManager } from '../services/contextManager.js';
@@ -159,6 +162,21 @@ export interface CliHelpAgentSettings {
enabled?: boolean; enabled?: boolean;
} }
export interface AgentRunConfig {
maxTimeMinutes?: number;
maxTurns?: number;
}
export interface AgentOverride {
modelConfig?: ModelConfig;
runConfig?: AgentRunConfig;
disabled?: boolean;
}
export interface AgentSettings {
overrides?: Record<string, AgentOverride>;
}
/** /**
* All information required in CLI to handle an extension. Defined in Core so * 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 * that the collection of loaded, active, and inactive extensions can be passed
@@ -364,6 +382,7 @@ export interface ConfigParameters {
onModelChange?: (model: string) => void; onModelChange?: (model: string) => void;
mcpEnabled?: boolean; mcpEnabled?: boolean;
extensionsEnabled?: boolean; extensionsEnabled?: boolean;
agents?: AgentSettings;
onReload?: () => Promise<{ disabledSkills?: string[] }>; onReload?: () => Promise<{ disabledSkills?: string[] }>;
} }
@@ -496,6 +515,7 @@ export class Config {
| undefined; | undefined;
private readonly enableAgents: boolean; private readonly enableAgents: boolean;
private readonly agents: AgentSettings;
private readonly skillsSupport: boolean; private readonly skillsSupport: boolean;
private disabledSkills: string[]; private disabledSkills: string[];
@@ -570,6 +590,7 @@ export class Config {
this.model = params.model; this.model = params.model;
this._activeModel = params.model; this._activeModel = params.model;
this.enableAgents = params.enableAgents ?? false; this.enableAgents = params.enableAgents ?? false;
this.agents = params.agents ?? {};
this.disableLLMCorrection = params.disableLLMCorrection ?? false; this.disableLLMCorrection = params.disableLLMCorrection ?? false;
this.skillsSupport = params.skillsSupport ?? false; this.skillsSupport = params.skillsSupport ?? false;
this.disabledSkills = params.disabledSkills ?? []; this.disabledSkills = params.disabledSkills ?? [];
@@ -1443,6 +1464,10 @@ export class Config {
return this.noBrowser; return this.noBrowser;
} }
getAgentsSettings(): AgentSettings {
return this.agents;
}
isBrowserLaunchSuppressed(): boolean { isBrowserLaunchSuppressed(): boolean {
return this.getNoBrowser() || !shouldAttemptBrowserLaunch(); return this.getNoBrowser() || !shouldAttemptBrowserLaunch();
} }
@@ -119,14 +119,10 @@ export class ModelConfigService {
...this.runtimeAliases, ...this.runtimeAliases,
}; };
const { const { aliasChain, baseModel, resolvedConfig } = this.resolveAliasChain(
aliasChain, context.model,
baseModel: initialBaseModel, allAliases,
resolvedConfig: initialResolvedConfig, );
} = this.resolveAliasChain(context.model, allAliases);
let baseModel = initialBaseModel;
let resolvedConfig = initialResolvedConfig;
const modelToLevel = this.buildModelLevelMap(aliasChain, baseModel); const modelToLevel = this.buildModelLevelMap(aliasChain, baseModel);
const allOverrides = [ const allOverrides = [
@@ -142,19 +138,22 @@ export class ModelConfigService {
this.sortOverrides(matches); this.sortOverrides(matches);
let currentConfig: ModelConfig = {
model: baseModel,
generateContentConfig: resolvedConfig,
};
for (const match of matches) { for (const match of matches) {
if (match.modelConfig.model) { currentConfig = ModelConfigService.merge(
baseModel = match.modelConfig.model; currentConfig,
} match.modelConfig,
if (match.modelConfig.generateContentConfig) { );
resolvedConfig = this.deepMerge(
resolvedConfig,
match.modelConfig.generateContentConfig,
);
}
} }
return { model: baseModel, generateContentConfig: resolvedConfig }; return {
model: currentConfig.model,
generateContentConfig: currentConfig.generateContentConfig ?? {},
};
} }
private resolveAliasChain( private resolveAliasChain(
@@ -165,8 +164,6 @@ export class ModelConfigService {
baseModel: string | undefined; baseModel: string | undefined;
resolvedConfig: GenerateContentConfig; resolvedConfig: GenerateContentConfig;
} { } {
let baseModel: string | undefined = undefined;
let resolvedConfig: GenerateContentConfig = {};
const aliasChain: string[] = []; const aliasChain: string[] = [];
if (allAliases[requestedModel]) { if (allAliases[requestedModel]) {
@@ -194,17 +191,19 @@ export class ModelConfigService {
// Root-to-Leaf chain for merging and level assignment. // Root-to-Leaf chain for merging and level assignment.
const reversedChain = [...aliasChain].reverse(); const reversedChain = [...aliasChain].reverse();
let resolvedConfig: ModelConfig = {};
for (const aliasName of reversedChain) { for (const aliasName of reversedChain) {
const alias = allAliases[aliasName]; const alias = allAliases[aliasName];
if (alias.modelConfig.model) { resolvedConfig = ModelConfigService.merge(
baseModel = alias.modelConfig.model;
}
resolvedConfig = this.deepMerge(
resolvedConfig, resolvedConfig,
alias.modelConfig.generateContentConfig, alias.modelConfig,
); );
} }
return { aliasChain: reversedChain, baseModel, resolvedConfig }; return {
aliasChain: reversedChain,
baseModel: resolvedConfig.model,
resolvedConfig: resolvedConfig.generateContentConfig ?? {},
};
} }
return { return {
@@ -298,21 +297,36 @@ export class ModelConfigService {
} as ResolvedModelConfig; } as ResolvedModelConfig;
} }
private isObject(item: unknown): item is Record<string, unknown> { static isObject(item: unknown): item is Record<string, unknown> {
return !!item && typeof item === 'object' && !Array.isArray(item); return !!item && typeof item === 'object' && !Array.isArray(item);
} }
private deepMerge( /**
config1: GenerateContentConfig | undefined, * Merges an override `ModelConfig` into a base `ModelConfig`.
config2: GenerateContentConfig | undefined, * The override's model name takes precedence if provided.
): Record<string, unknown> { * The `generateContentConfig` properties are deeply merged.
return this.genericDeepMerge( */
config1 as Record<string, unknown> | undefined, static merge(base: ModelConfig, override: ModelConfig): ModelConfig {
config2 as Record<string, unknown> | undefined, 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<string, unknown> | undefined,
config2 as Record<string, unknown> | undefined,
) as GenerateContentConfig;
}
private static genericDeepMerge(
...objects: Array<Record<string, unknown> | undefined> ...objects: Array<Record<string, unknown> | undefined>
): Record<string, unknown> { ): Record<string, unknown> {
return objects.reduce((acc: Record<string, unknown>, obj) => { return objects.reduce((acc: Record<string, unknown>, obj) => {
@@ -329,8 +343,11 @@ export class ModelConfigService {
// override the base array. // override the base array.
// TODO(joshualitt): Consider knobs here, i.e. opt-in to deep merging // TODO(joshualitt): Consider knobs here, i.e. opt-in to deep merging
// arrays on a case-by-case basis. // arrays on a case-by-case basis.
if (this.isObject(accValue) && this.isObject(objValue)) { if (
acc[key] = this.deepMerge(accValue, objValue); ModelConfigService.isObject(accValue) &&
ModelConfigService.isObject(objValue)
) {
acc[key] = ModelConfigService.genericDeepMerge(accValue, objValue);
} else { } else {
acc[key] = objValue; acc[key] = objValue;
} }
+50
View File
@@ -926,6 +926,26 @@
}, },
"additionalProperties": false "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": { "context": {
"title": "Context", "title": "Context",
"description": "Settings for managing context provided to the model.", "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": { "CustomTheme": {
"type": "object", "type": "object",
"description": "Custom theme definition used for styling Gemini CLI output. Colors are provided as hex strings or named ANSI colors.", "description": "Custom theme definition used for styling Gemini CLI output. Colors are provided as hex strings or named ANSI colors.",