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

View File

@@ -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[]):

View File

@@ -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,

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: {
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:

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', () => {
it('should return default message when no agents are registered', () => {
expect(registry.getToolDescription()).toContain(

View File

@@ -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}'`);
}

View File

@@ -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<string, AgentOverride>;
}
/**
* 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();
}

View File

@@ -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<string, unknown> {
static isObject(item: unknown): item is Record<string, unknown> {
return !!item && typeof item === 'object' && !Array.isArray(item);
}
private deepMerge(
config1: GenerateContentConfig | undefined,
config2: GenerateContentConfig | undefined,
): Record<string, unknown> {
return this.genericDeepMerge(
config1 as Record<string, unknown> | undefined,
config2 as Record<string, unknown> | 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<string, unknown> | undefined,
config2 as Record<string, unknown> | undefined,
) as GenerateContentConfig;
}
private static genericDeepMerge(
...objects: Array<Record<string, unknown> | undefined>
): Record<string, unknown> {
return objects.reduce((acc: Record<string, unknown>, 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;
}

View File

@@ -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.",