From 4b2e9f79545c8b00c18395c3555e29600ef5809c Mon Sep 17 00:00:00 2001 From: Sehoon Shon Date: Wed, 14 Jan 2026 19:30:17 -0500 Subject: [PATCH] Enable & disable agents (#16225) --- packages/cli/src/config/config.ts | 4 +- .../cli/src/ui/commands/agentsCommand.test.ts | 201 ++++++++++++++++- packages/cli/src/ui/commands/agentsCommand.ts | 208 +++++++++++++++++- packages/cli/src/utils/agentSettings.ts | 150 +++++++++++++ packages/cli/src/utils/agentUtils.test.ts | 150 +++++++++++++ packages/cli/src/utils/agentUtils.ts | 65 ++++++ .../core/src/agents/a2a-client-manager.ts | 2 +- packages/core/src/agents/registry.ts | 17 +- packages/core/src/config/config.ts | 16 +- 9 files changed, 800 insertions(+), 13 deletions(-) create mode 100644 packages/cli/src/utils/agentSettings.ts create mode 100644 packages/cli/src/utils/agentUtils.test.ts create mode 100644 packages/cli/src/utils/agentUtils.ts diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index fc21e43fce..137e01d943 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -708,7 +708,6 @@ export async function loadCliConfig( enableAgents: settings.experimental?.enableAgents, skillsSupport: settings.experimental?.skills, disabledSkills: settings.skills?.disabled, - experimentalJitContext: settings.experimental?.jitContext, noBrowser: !!process.env['NO_BROWSER'], summarizeToolOutput: settings.model?.summarizeToolOutput, @@ -751,8 +750,7 @@ export async function loadCliConfig( const refreshedSettings = loadSettings(cwd); return { disabledSkills: refreshedSettings.merged.skills?.disabled, - adminSkillsEnabled: - refreshedSettings.merged.admin?.skills?.enabled ?? adminSkillsEnabled, + agents: refreshedSettings.merged.agents, }; }, }); diff --git a/packages/cli/src/ui/commands/agentsCommand.test.ts b/packages/cli/src/ui/commands/agentsCommand.test.ts index bc84252cf2..f126ddd8ee 100644 --- a/packages/cli/src/ui/commands/agentsCommand.test.ts +++ b/packages/cli/src/ui/commands/agentsCommand.test.ts @@ -7,8 +7,20 @@ import { vi, describe, it, expect, beforeEach } from 'vitest'; import { agentsCommand } from './agentsCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; -import type { Config } from '@google/gemini-cli-core'; +import type { Config, AgentOverride } from '@google/gemini-cli-core'; +import type { LoadedSettings } from '../../config/settings.js'; import { MessageType } from '../types.js'; +import { enableAgent, disableAgent } from '../../utils/agentSettings.js'; +import { renderAgentActionFeedback } from '../../utils/agentUtils.js'; + +vi.mock('../../utils/agentSettings.js', () => ({ + enableAgent: vi.fn(), + disableAgent: vi.fn(), +})); + +vi.mock('../../utils/agentUtils.js', () => ({ + renderAgentActionFeedback: vi.fn(), +})); describe('agentsCommand', () => { let mockContext: ReturnType; @@ -22,12 +34,18 @@ describe('agentsCommand', () => { mockConfig = { getAgentRegistry: vi.fn().mockReturnValue({ getAllDefinitions: vi.fn().mockReturnValue([]), + getAllAgentNames: vi.fn().mockReturnValue([]), + reload: vi.fn(), }), }; mockContext = createMockCommandContext({ services: { config: mockConfig as unknown as Config, + settings: { + workspace: { path: '/mock/path' }, + merged: { agents: { overrides: {} } }, + } as unknown as LoadedSettings, }, }); }); @@ -68,7 +86,12 @@ describe('agentsCommand', () => { description: 'desc1', kind: 'local', }, - { name: 'agent2', description: 'desc2', kind: 'remote' }, + { + name: 'agent2', + displayName: undefined, + description: 'desc2', + kind: 'remote', + }, ]; mockConfig.getAgentRegistry().getAllDefinitions.mockReturnValue(mockAgents); @@ -117,4 +140,178 @@ describe('agentsCommand', () => { content: 'Agent registry not found.', }); }); + + it('should enable an agent successfully', async () => { + const reloadSpy = vi.fn().mockResolvedValue(undefined); + mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ + getAllAgentNames: vi.fn().mockReturnValue([]), + reload: reloadSpy, + }); + // Add agent to disabled overrides so validation passes + ( + mockContext.services.settings.merged.agents!.overrides as Record< + string, + AgentOverride + > + )['test-agent'] = { disabled: true }; + + vi.mocked(enableAgent).mockReturnValue({ + status: 'success', + agentName: 'test-agent', + action: 'enable', + modifiedScopes: [], + alreadyInStateScopes: [], + }); + vi.mocked(renderAgentActionFeedback).mockReturnValue('Enabled test-agent.'); + + const enableCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'enable', + ); + expect(enableCommand).toBeDefined(); + + const result = await enableCommand!.action!(mockContext, 'test-agent'); + + expect(enableAgent).toHaveBeenCalledWith( + mockContext.services.settings, + 'test-agent', + ); + expect(reloadSpy).toHaveBeenCalled(); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Enabling test-agent...', + }), + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Enabled test-agent.', + }); + }); + + it('should handle no-op when enabling an agent', async () => { + mockConfig + .getAgentRegistry() + .getAllAgentNames.mockReturnValue(['test-agent']); + + const enableCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'enable', + ); + const result = await enableCommand!.action!(mockContext, 'test-agent'); + + expect(enableAgent).not.toHaveBeenCalled(); + expect(mockContext.ui.addItem).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: "Agent 'test-agent' is already enabled.", + }); + }); + + it('should show usage error if no agent name provided for enable', async () => { + const enableCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'enable', + ); + const result = await enableCommand!.action!(mockContext, ' '); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Usage: /agents enable ', + }); + }); + + it('should disable an agent successfully', async () => { + const reloadSpy = vi.fn().mockResolvedValue(undefined); + mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ + getAllAgentNames: vi.fn().mockReturnValue(['test-agent']), + reload: reloadSpy, + }); + vi.mocked(disableAgent).mockReturnValue({ + status: 'success', + agentName: 'test-agent', + action: 'disable', + modifiedScopes: [], + alreadyInStateScopes: [], + }); + vi.mocked(renderAgentActionFeedback).mockReturnValue( + 'Disabled test-agent.', + ); + + const disableCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'disable', + ); + expect(disableCommand).toBeDefined(); + + const result = await disableCommand!.action!(mockContext, 'test-agent'); + + expect(disableAgent).toHaveBeenCalledWith( + mockContext.services.settings, + 'test-agent', + expect.anything(), // Scope is derived in the command + ); + expect(reloadSpy).toHaveBeenCalled(); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Disabling test-agent...', + }), + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Disabled test-agent.', + }); + }); + + it('should show info message if agent is already disabled', async () => { + mockConfig.getAgentRegistry().getAllAgentNames.mockReturnValue([]); + ( + mockContext.services.settings.merged.agents!.overrides as Record< + string, + AgentOverride + > + )['test-agent'] = { disabled: true }; + + const disableCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'disable', + ); + const result = await disableCommand!.action!(mockContext, 'test-agent'); + + expect(disableAgent).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: "Agent 'test-agent' is already disabled.", + }); + }); + + it('should show error if agent is not found when disabling', async () => { + mockConfig.getAgentRegistry().getAllAgentNames.mockReturnValue([]); + + const disableCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'disable', + ); + const result = await disableCommand!.action!(mockContext, 'test-agent'); + + expect(disableAgent).not.toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: "Agent 'test-agent' not found.", + }); + }); + + it('should show usage error if no agent name provided for disable', async () => { + const disableCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'disable', + ); + const result = await disableCommand!.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Usage: /agents disable ', + }); + }); }); diff --git a/packages/cli/src/ui/commands/agentsCommand.ts b/packages/cli/src/ui/commands/agentsCommand.ts index 5059fc1937..1c03524332 100644 --- a/packages/cli/src/ui/commands/agentsCommand.ts +++ b/packages/cli/src/ui/commands/agentsCommand.ts @@ -4,9 +4,17 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { SlashCommand, CommandContext } from './types.js'; +import type { + SlashCommand, + CommandContext, + SlashCommandActionReturn, +} from './types.js'; import { CommandKind } from './types.js'; import { MessageType, type HistoryItemAgentsList } from '../types.js'; +import { SettingScope } from '../../config/settings.js'; +import type { AgentOverride } from '@google/gemini-cli-core'; +import { disableAgent, enableAgent } from '../../utils/agentSettings.js'; +import { renderAgentActionFeedback } from '../../utils/agentUtils.js'; const agentsListCommand: SlashCommand = { name: 'list', @@ -50,6 +58,197 @@ const agentsListCommand: SlashCommand = { }, }; +async function enableAction( + context: CommandContext, + args: string, +): Promise { + const { config, settings } = context.services; + if (!config) return; + + const agentName = args.trim(); + if (!agentName) { + return { + type: 'message', + messageType: 'error', + content: 'Usage: /agents enable ', + }; + } + + const agentRegistry = config.getAgentRegistry(); + if (!agentRegistry) { + return { + type: 'message', + messageType: 'error', + content: 'Agent registry not found.', + }; + } + + const allAgents = agentRegistry.getAllAgentNames(); + const overrides = (settings.merged.agents?.overrides ?? {}) as Record< + string, + AgentOverride + >; + const disabledAgents = Object.keys(overrides).filter( + (name) => overrides[name]?.disabled === true, + ); + + if (allAgents.includes(agentName)) { + return { + type: 'message', + messageType: 'info', + content: `Agent '${agentName}' is already enabled.`, + }; + } + + if (!disabledAgents.includes(agentName)) { + return { + type: 'message', + messageType: 'error', + content: `Agent '${agentName}' not found.`, + }; + } + + const result = enableAgent(settings, agentName); + + if (result.status === 'no-op') { + return { + type: 'message', + messageType: 'info', + content: renderAgentActionFeedback(result, (l, p) => `${l} (${p})`), + }; + } + + context.ui.addItem({ + type: MessageType.INFO, + text: `Enabling ${agentName}...`, + }); + await agentRegistry.reload(); + + return { + type: 'message', + messageType: 'info', + content: renderAgentActionFeedback(result, (l, p) => `${l} (${p})`), + }; +} + +async function disableAction( + context: CommandContext, + args: string, +): Promise { + const { config, settings } = context.services; + if (!config) return; + + const agentName = args.trim(); + if (!agentName) { + return { + type: 'message', + messageType: 'error', + content: 'Usage: /agents disable ', + }; + } + + const agentRegistry = config.getAgentRegistry(); + if (!agentRegistry) { + return { + type: 'message', + messageType: 'error', + content: 'Agent registry not found.', + }; + } + + const allAgents = agentRegistry.getAllAgentNames(); + const overrides = (settings.merged.agents?.overrides ?? {}) as Record< + string, + AgentOverride + >; + const disabledAgents = Object.keys(overrides).filter( + (name) => overrides[name]?.disabled === true, + ); + + if (disabledAgents.includes(agentName)) { + return { + type: 'message', + messageType: 'info', + content: `Agent '${agentName}' is already disabled.`, + }; + } + + if (!allAgents.includes(agentName)) { + return { + type: 'message', + messageType: 'error', + content: `Agent '${agentName}' not found.`, + }; + } + + const scope = context.services.settings.workspace.path + ? SettingScope.Workspace + : SettingScope.User; + const result = disableAgent(settings, agentName, scope); + + if (result.status === 'no-op') { + return { + type: 'message', + messageType: 'info', + content: renderAgentActionFeedback(result, (l, p) => `${l} (${p})`), + }; + } + + context.ui.addItem({ + type: MessageType.INFO, + text: `Disabling ${agentName}...`, + }); + await agentRegistry.reload(); + + return { + type: 'message', + messageType: 'info', + content: renderAgentActionFeedback(result, (l, p) => `${l} (${p})`), + }; +} + +function completeAgentsToEnable(context: CommandContext, partialArg: string) { + const { config, settings } = context.services; + if (!config) return []; + + const overrides = (settings.merged.agents?.overrides ?? {}) as Record< + string, + AgentOverride + >; + const disabledAgents = Object.entries(overrides) + .filter(([_, override]) => override?.disabled === true) + .map(([name]) => name); + + return disabledAgents.filter((name) => name.startsWith(partialArg)); +} + +function completeAgentsToDisable(context: CommandContext, partialArg: string) { + const { config } = context.services; + if (!config) return []; + + const agentRegistry = config.getAgentRegistry(); + const allAgents = agentRegistry ? agentRegistry.getAllAgentNames() : []; + return allAgents.filter((name: string) => name.startsWith(partialArg)); +} + +const enableCommand: SlashCommand = { + name: 'enable', + description: 'Enable a disabled agent', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: enableAction, + completion: completeAgentsToEnable, +}; + +const disableCommand: SlashCommand = { + name: 'disable', + description: 'Disable an enabled agent', + kind: CommandKind.BUILT_IN, + autoExecute: false, + action: disableAction, + completion: completeAgentsToDisable, +}; + const agentsRefreshCommand: SlashCommand = { name: 'refresh', description: 'Reload the agent registry', @@ -84,7 +283,12 @@ export const agentsCommand: SlashCommand = { name: 'agents', description: 'Manage agents', kind: CommandKind.BUILT_IN, - subCommands: [agentsListCommand, agentsRefreshCommand], + subCommands: [ + agentsListCommand, + agentsRefreshCommand, + enableCommand, + disableCommand, + ], action: async (context: CommandContext, args) => // Default to list if no subcommand is provided agentsListCommand.action!(context, args), diff --git a/packages/cli/src/utils/agentSettings.ts b/packages/cli/src/utils/agentSettings.ts new file mode 100644 index 0000000000..adf444c4ba --- /dev/null +++ b/packages/cli/src/utils/agentSettings.ts @@ -0,0 +1,150 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + SettingScope, + isLoadableSettingScope, + type LoadedSettings, +} from '../config/settings.js'; +import type { ModifiedScope } from './skillSettings.js'; +import type { AgentOverride } from '@google/gemini-cli-core'; + +export type AgentActionStatus = 'success' | 'no-op' | 'error'; + +/** + * Metadata representing the result of an agent settings operation. + */ +export interface AgentActionResult { + status: AgentActionStatus; + agentName: string; + action: 'enable' | 'disable'; + /** Scopes where the agent's state was actually changed. */ + modifiedScopes: ModifiedScope[]; + /** Scopes where the agent was already in the desired state. */ + alreadyInStateScopes: ModifiedScope[]; + /** Error message if status is 'error'. */ + error?: string; +} + +/** + * Enables an agent by ensuring it is not disabled in any writable scope (User and Workspace). + * It sets `agents.overrides..disabled` to `false` if it was found to be `true`. + */ +export function enableAgent( + settings: LoadedSettings, + agentName: string, +): AgentActionResult { + const writableScopes = [SettingScope.Workspace, SettingScope.User]; + const foundInDisabledScopes: ModifiedScope[] = []; + const alreadyEnabledScopes: ModifiedScope[] = []; + + for (const scope of writableScopes) { + if (isLoadableSettingScope(scope)) { + const scopePath = settings.forScope(scope).path; + const agentOverrides = settings.forScope(scope).settings.agents + ?.overrides as Record | undefined; + const isDisabled = agentOverrides?.[agentName]?.disabled === true; + + if (isDisabled) { + foundInDisabledScopes.push({ scope, path: scopePath }); + } else { + alreadyEnabledScopes.push({ scope, path: scopePath }); + } + } + } + + if (foundInDisabledScopes.length === 0) { + return { + status: 'no-op', + agentName, + action: 'enable', + modifiedScopes: [], + alreadyInStateScopes: alreadyEnabledScopes, + }; + } + + const modifiedScopes: ModifiedScope[] = []; + for (const { scope, path } of foundInDisabledScopes) { + if (isLoadableSettingScope(scope)) { + // Explicitly enable it to override any lower-precedence disables, or just clear the disable. + // Setting to false ensures it is enabled. + settings.setValue(scope, `agents.overrides.${agentName}.disabled`, false); + modifiedScopes.push({ scope, path }); + } + } + + return { + status: 'success', + agentName, + action: 'enable', + modifiedScopes, + alreadyInStateScopes: alreadyEnabledScopes, + }; +} + +/** + * Disables an agent by setting `agents.overrides..disabled` to `true` in the specified scope. + */ +export function disableAgent( + settings: LoadedSettings, + agentName: string, + scope: SettingScope, +): AgentActionResult { + if (!isLoadableSettingScope(scope)) { + return { + status: 'error', + agentName, + action: 'disable', + modifiedScopes: [], + alreadyInStateScopes: [], + error: `Invalid settings scope: ${scope}`, + }; + } + + const scopePath = settings.forScope(scope).path; + const agentOverrides = settings.forScope(scope).settings.agents?.overrides as + | Record + | undefined; + const isDisabled = agentOverrides?.[agentName]?.disabled === true; + + if (isDisabled) { + return { + status: 'no-op', + agentName, + action: 'disable', + modifiedScopes: [], + alreadyInStateScopes: [{ scope, path: scopePath }], + }; + } + + // Check if it's already disabled in the other writable scope + const otherScope = + scope === SettingScope.Workspace + ? SettingScope.User + : SettingScope.Workspace; + const alreadyDisabledInOther: ModifiedScope[] = []; + + if (isLoadableSettingScope(otherScope)) { + const otherOverrides = settings.forScope(otherScope).settings.agents + ?.overrides as Record | undefined; + if (otherOverrides?.[agentName]?.disabled === true) { + alreadyDisabledInOther.push({ + scope: otherScope, + path: settings.forScope(otherScope).path, + }); + } + } + + settings.setValue(scope, `agents.overrides.${agentName}.disabled`, true); + + return { + status: 'success', + agentName, + action: 'disable', + modifiedScopes: [{ scope, path: scopePath }], + alreadyInStateScopes: alreadyDisabledInOther, + }; +} diff --git a/packages/cli/src/utils/agentUtils.test.ts b/packages/cli/src/utils/agentUtils.test.ts new file mode 100644 index 0000000000..e62fb7f1f2 --- /dev/null +++ b/packages/cli/src/utils/agentUtils.test.ts @@ -0,0 +1,150 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('../config/settings.js', () => ({ + SettingScope: { + User: 'User', + Workspace: 'Workspace', + System: 'System', + SystemDefaults: 'SystemDefaults', + }, +})); + +import { renderAgentActionFeedback } from './agentUtils.js'; +import { SettingScope } from '../config/settings.js'; +import type { AgentActionResult } from './agentSettings.js'; + +describe('agentUtils', () => { + describe('renderAgentActionFeedback', () => { + const mockFormatScope = (label: string, path: string) => + `[${label}:${path}]`; + + it('should return error message if status is error', () => { + const result: AgentActionResult = { + status: 'error', + agentName: 'my-agent', + action: 'enable', + modifiedScopes: [], + alreadyInStateScopes: [], + error: 'Something went wrong', + }; + expect(renderAgentActionFeedback(result, mockFormatScope)).toBe( + 'Something went wrong', + ); + }); + + it('should return default error message if status is error and no error message provided', () => { + const result: AgentActionResult = { + status: 'error', + agentName: 'my-agent', + action: 'enable', + modifiedScopes: [], + alreadyInStateScopes: [], + }; + expect(renderAgentActionFeedback(result, mockFormatScope)).toBe( + 'An error occurred while attempting to enable agent "my-agent".', + ); + }); + + it('should return no-op message for enable', () => { + const result: AgentActionResult = { + status: 'no-op', + agentName: 'my-agent', + action: 'enable', + modifiedScopes: [], + alreadyInStateScopes: [], + }; + expect(renderAgentActionFeedback(result, mockFormatScope)).toBe( + 'Agent "my-agent" is already enabled.', + ); + }); + + it('should return no-op message for disable', () => { + const result: AgentActionResult = { + status: 'no-op', + agentName: 'my-agent', + action: 'disable', + modifiedScopes: [], + alreadyInStateScopes: [], + }; + expect(renderAgentActionFeedback(result, mockFormatScope)).toBe( + 'Agent "my-agent" is already disabled.', + ); + }); + + it('should return success message for enable (single scope)', () => { + const result: AgentActionResult = { + status: 'success', + agentName: 'my-agent', + action: 'enable', + modifiedScopes: [ + { scope: SettingScope.User, path: '/path/to/user/settings' }, + ], + alreadyInStateScopes: [], + }; + expect(renderAgentActionFeedback(result, mockFormatScope)).toBe( + 'Agent "my-agent" enabled by setting it to enabled in [user:/path/to/user/settings] settings.', + ); + }); + + it('should return success message for enable (two scopes)', () => { + const result: AgentActionResult = { + status: 'success', + agentName: 'my-agent', + action: 'enable', + modifiedScopes: [ + { scope: SettingScope.User, path: '/path/to/user/settings' }, + ], + alreadyInStateScopes: [ + { + scope: SettingScope.Workspace, + path: '/path/to/workspace/settings', + }, + ], + }; + expect(renderAgentActionFeedback(result, mockFormatScope)).toBe( + 'Agent "my-agent" enabled by setting it to enabled in [user:/path/to/user/settings] and [project:/path/to/workspace/settings] settings.', + ); + }); + + it('should return success message for disable (single scope)', () => { + const result: AgentActionResult = { + status: 'success', + agentName: 'my-agent', + action: 'disable', + modifiedScopes: [ + { scope: SettingScope.User, path: '/path/to/user/settings' }, + ], + alreadyInStateScopes: [], + }; + expect(renderAgentActionFeedback(result, mockFormatScope)).toBe( + 'Agent "my-agent" disabled by setting it to disabled in [user:/path/to/user/settings] settings.', + ); + }); + + it('should return success message for disable (two scopes)', () => { + const result: AgentActionResult = { + status: 'success', + agentName: 'my-agent', + action: 'disable', + modifiedScopes: [ + { scope: SettingScope.User, path: '/path/to/user/settings' }, + ], + alreadyInStateScopes: [ + { + scope: SettingScope.Workspace, + path: '/path/to/workspace/settings', + }, + ], + }; + expect(renderAgentActionFeedback(result, mockFormatScope)).toBe( + 'Agent "my-agent" is now disabled in both [user:/path/to/user/settings] and [project:/path/to/workspace/settings] settings.', + ); + }); + }); +}); diff --git a/packages/cli/src/utils/agentUtils.ts b/packages/cli/src/utils/agentUtils.ts new file mode 100644 index 0000000000..4bcee796d1 --- /dev/null +++ b/packages/cli/src/utils/agentUtils.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SettingScope } from '../config/settings.js'; +import type { AgentActionResult } from './agentSettings.js'; + +/** + * Shared logic for building the core agent action message while allowing the + * caller to control how each scope and its path are rendered (e.g., bolding or + * dimming). + * + * This function ONLY returns the description of what happened. It is up to the + * caller to append any interface-specific guidance. + */ +export function renderAgentActionFeedback( + result: AgentActionResult, + formatScope: (label: string, path: string) => string, +): string { + const { agentName, action, status, error } = result; + + if (status === 'error') { + return ( + error || + `An error occurred while attempting to ${action} agent "${agentName}".` + ); + } + + if (status === 'no-op') { + return `Agent "${agentName}" is already ${action === 'enable' ? 'enabled' : 'disabled'}.`; + } + + const isEnable = action === 'enable'; + const actionVerb = isEnable ? 'enabled' : 'disabled'; + const preposition = isEnable + ? 'by setting it to enabled in' + : 'by setting it to disabled in'; + + const formatScopeItem = (s: { scope: SettingScope; path: string }) => { + const label = + s.scope === SettingScope.Workspace ? 'project' : s.scope.toLowerCase(); + return formatScope(label, s.path); + }; + + const totalAffectedScopes = [ + ...result.modifiedScopes, + ...result.alreadyInStateScopes, + ]; + + if (totalAffectedScopes.length === 2) { + const s1 = formatScopeItem(totalAffectedScopes[0]); + const s2 = formatScopeItem(totalAffectedScopes[1]); + + if (isEnable) { + return `Agent "${agentName}" ${actionVerb} ${preposition} ${s1} and ${s2} settings.`; + } else { + return `Agent "${agentName}" is now disabled in both ${s1} and ${s2} settings.`; + } + } + + const s = formatScopeItem(totalAffectedScopes[0]); + return `Agent "${agentName}" ${actionVerb} ${preposition} ${s} settings.`; +} diff --git a/packages/core/src/agents/a2a-client-manager.ts b/packages/core/src/agents/a2a-client-manager.ts index ff379f1719..97355eef06 100644 --- a/packages/core/src/agents/a2a-client-manager.ts +++ b/packages/core/src/agents/a2a-client-manager.ts @@ -64,7 +64,7 @@ export class A2AClientManager { agentCardUrl: string, authHandler?: AuthenticationHandler, ): Promise { - if (this.clients.has(name)) { + if (this.clients.has(name) && this.agentCards.has(name)) { throw new Error(`Agent with name '${name}' is already loaded.`); } diff --git a/packages/core/src/agents/registry.ts b/packages/core/src/agents/registry.ts index 49d425bd6e..4e042ab711 100644 --- a/packages/core/src/agents/registry.ts +++ b/packages/core/src/agents/registry.ts @@ -69,6 +69,7 @@ export class AgentRegistry { */ async reload(): Promise { A2AClientManager.getInstance().clearCache(); + await this.config.reloadAgents(); this.agents.clear(); await this.loadAgents(); coreEvents.emitAgentsRefreshed(); @@ -143,9 +144,14 @@ export class AgentRegistry { private loadBuiltInAgents(): void { const investigatorSettings = this.config.getCodebaseInvestigatorSettings(); const cliHelpSettings = this.config.getCliHelpAgentSettings(); + const agentsSettings = this.config.getAgentsSettings(); + const agentsOverrides = agentsSettings.overrides ?? {}; - // Only register the agent if it's enabled in the settings. - if (investigatorSettings?.enabled) { + // Only register the agent if it's enabled in the settings and not explicitly disabled via overrides. + if ( + investigatorSettings?.enabled && + !agentsOverrides[CodebaseInvestigatorAgent.name]?.disabled + ) { let model; const settingsModel = investigatorSettings.model; // Check if the user explicitly set a model in the settings. @@ -189,8 +195,11 @@ export class AgentRegistry { this.registerLocalAgent(agentDef); } - // Register the CLI help agent if it's explicitly enabled. - if (cliHelpSettings.enabled) { + // Register the CLI help agent if it's explicitly enabled and not explicitly disabled via overrides. + if ( + cliHelpSettings.enabled && + !agentsOverrides[CliHelpAgent.name]?.disabled + ) { this.registerLocalAgent(CliHelpAgent(this.config)); } } diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 5db98732b1..5e77a93ab8 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -387,6 +387,7 @@ export interface ConfigParameters { onReload?: () => Promise<{ disabledSkills?: string[]; adminSkillsEnabled?: boolean; + agents?: AgentSettings; }>; } @@ -518,11 +519,12 @@ export class Config { | (() => Promise<{ disabledSkills?: string[]; adminSkillsEnabled?: boolean; + agents?: AgentSettings; }>) | undefined; private readonly enableAgents: boolean; - private readonly agents: AgentSettings; + private agents: AgentSettings; private readonly skillsSupport: boolean; private disabledSkills: string[]; private readonly adminSkillsEnabled: boolean; @@ -1634,6 +1636,18 @@ export class Config { await this.updateSystemInstructionIfInitialized(); } + /** + * Reloads agent settings. + */ + async reloadAgents(): Promise { + if (this.onReload) { + const refreshed = await this.onReload(); + if (refreshed.agents) { + this.agents = refreshed.agents; + } + } + } + isInteractive(): boolean { return this.interactive; }