/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as crypto from 'node:crypto'; import { Storage } from '../config/storage.js'; import { CoreEvent, coreEvents } from '../utils/events.js'; import type { AgentOverride, Config } from '../config/config.js'; import { type AgentDefinition, type LocalAgentDefinition, type AgentReloadSummary, } from './types.js'; import { getAgentCardLoadOptions, getRemoteAgentTargetUrl } 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 { BrowserAgentDefinition } from './browser/browserAgentDefinition.js'; import { AgentTool } from './agent-tool.js'; import { A2AAuthProviderFactory } from './auth-provider/factory.js'; import type { AuthenticationHandler } from '@a2a-js/sdk/client'; import { type z } from 'zod'; import { debugLogger } from '../utils/debugLogger.js'; import { isAutoModel } from '../config/models.js'; import { type ModelConfig, ModelConfigService, } from '../services/modelConfigService.js'; import { PolicyDecision, PRIORITY_SUBAGENT_TOOL } from '../policy/types.js'; import { A2AAgentError, AgentAuthConfigMissingError } from './a2a-errors.js'; /** * Returns the model config alias for a given agent definition. */ export function getModelConfigAlias( definition: AgentDefinition, ): string { return `${definition.name}-config`; } export const DYNAMIC_RULE_SOURCE = 'AgentRegistry (Dynamic)'; /** * Manages the discovery, loading, validation, and registration of * AgentDefinitions. */ export class AgentRegistry { // eslint-disable-next-line @typescript-eslint/no-explicit-any private readonly agents = new Map>(); // eslint-disable-next-line @typescript-eslint/no-explicit-any private readonly allDefinitions = new Map>(); private initialized = false; constructor(private readonly config: Config) {} /** * Discovers and loads agents. */ async initialize(): Promise { if (this.initialized) { await this.loadAgents(); return; } this.initialized = true; coreEvents.on(CoreEvent.ModelChanged, this.onModelChanged); await this.loadAgents(); } private onModelChanged = () => { this.refreshAgents('local').catch((e) => { debugLogger.error( '[AgentRegistry] Failed to refresh agents on model change:', e, ); }); }; /** * Clears the current registry and re-scans for agents. */ async reload(): Promise { const previousAgents = new Map(this.agents); const reloadErrors: string[] = []; this.config.getA2AClientManager()?.clearCache(); await this.config.reloadAgents(); await this.loadAgents(reloadErrors); const currentAgents = Array.from(this.agents.values()); const newAgents: string[] = []; const updatedAgents: string[] = []; const deletedAgents: string[] = []; let localCount = 0; let remoteCount = 0; for (const agent of currentAgents) { if (agent.kind === 'local') { localCount++; } else if (agent.kind === 'remote') { remoteCount++; } const prev = previousAgents.get(agent.name); if (!prev) { newAgents.push(agent.name); } else if (agent.metadata?.hash !== prev.metadata?.hash) { updatedAgents.push(agent.name); } } for (const prevName of previousAgents.keys()) { if (!this.agents.has(prevName)) { deletedAgents.push(prevName); } } coreEvents.emitAgentsRefreshed(); return { totalLoaded: currentAgents.length, localCount, remoteCount, newAgents, updatedAgents, deletedAgents, errors: reloadErrors, }; } /** * Acknowledges and registers a previously unacknowledged agent. */ async acknowledgeAgent(agent: AgentDefinition): Promise { const ackService = this.config.getAcknowledgedAgentsService(); const projectRoot = this.config.getProjectRoot(); if (agent.metadata?.hash) { await ackService.acknowledge( projectRoot, agent.name, agent.metadata.hash, ); await this.registerAgent(agent); coreEvents.emitAgentsRefreshed(); } } /** * Disposes of resources and removes event listeners. */ dispose(): void { coreEvents.off(CoreEvent.ModelChanged, this.onModelChanged); } private async loadAgents(errors?: string[]): Promise { this.agents.clear(); this.allDefinitions.clear(); this.loadBuiltInAgents(); // Clear old dynamic rules before reloading this.config.getPolicyEngine()?.removeRulesBySource(DYNAMIC_RULE_SOURCE); if (!this.config.isAgentsEnabled()) { return; } // Load project-level agents: .gemini/agents/ (relative to Project Root) const folderTrustEnabled = this.config.getFolderTrust(); const isTrustedFolder = this.config.isTrustedFolder(); if (!folderTrustEnabled || isTrustedFolder) { const projectAgentsDir = this.config.storage.getProjectAgentsDir(); const projectAgents = await loadAgentsFromDirectory(projectAgentsDir); for (const error of projectAgents.errors) { const msg = `Agent loading error: ${error.message}`; errors?.push(msg); coreEvents.emitFeedback('error', msg); } const ackService = this.config.getAcknowledgedAgentsService(); const projectRoot = this.config.getProjectRoot(); const unacknowledgedAgents: AgentDefinition[] = []; const agentsToRegister: AgentDefinition[] = []; for (const agent of projectAgents.agents) { this.ensureRemoteAgentHash(agent); if (!agent.metadata?.hash) { agentsToRegister.push(agent); continue; } const isAcknowledged = await ackService.isAcknowledged( projectRoot, agent.name, agent.metadata.hash, ); if (isAcknowledged) { agentsToRegister.push(agent); } else { unacknowledgedAgents.push(agent); } } if (unacknowledgedAgents.length > 0) { coreEvents.emitAgentsDiscovered(unacknowledgedAgents); } await Promise.allSettled( agentsToRegister.map(async (agent) => { try { await this.registerAgent(agent, errors); } catch (e) { const msg = `Error registering project agent "${agent.name}": ${e instanceof Error ? e.message : String(e)}`; debugLogger.warn(`[AgentRegistry] ${msg}`, e); errors?.push(msg); coreEvents.emitFeedback('error', msg); } }), ); } else { coreEvents.emitFeedback( 'info', 'Skipping project agents due to untrusted folder. To enable, ensure that the project root is trusted.', ); } // Load user-level agents: ~/.gemini/agents/ const userAgentsDir = Storage.getUserAgentsDir(); const userAgents = await loadAgentsFromDirectory(userAgentsDir); for (const error of userAgents.errors) { debugLogger.warn( `[AgentRegistry] Error loading user agent: ${error.message}`, ); const msg = `Agent loading error: ${error.message}`; errors?.push(msg); coreEvents.emitFeedback('error', msg); } await Promise.allSettled( userAgents.agents.map(async (agent) => { try { this.ensureRemoteAgentHash(agent); await this.registerAgent(agent, errors); } catch (e) { const msg = `Error registering user agent "${agent.name}": ${e instanceof Error ? e.message : String(e)}`; debugLogger.warn(`[AgentRegistry] ${msg}`, e); errors?.push(msg); coreEvents.emitFeedback('error', msg); } }), ); // Load agents from extensions for (const extension of this.config.getExtensions()) { if (extension.isActive && extension.agents) { await Promise.allSettled( extension.agents.map(async (agent) => { try { await this.registerAgent(agent, errors); } catch (e) { const msg = `Error registering extension agent "${agent.name}": ${e instanceof Error ? e.message : String(e)}`; debugLogger.warn(`[AgentRegistry] ${msg}`, e); errors?.push(msg); coreEvents.emitFeedback('error', msg); } }), ); } } if (this.config.getDebugMode()) { debugLogger.log( `[AgentRegistry] Loaded with ${this.agents.size} agents.`, ); } } private loadBuiltInAgents(): void { this.registerLocalAgent(CodebaseInvestigatorAgent(this.config)); this.registerLocalAgent(CliHelpAgent(this.config)); this.registerLocalAgent(GeneralistAgent(this.config)); // Register the browser agent if enabled in settings. // Tools are configured dynamically at invocation time via browserAgentFactory. const browserConfig = this.config.getBrowserAgentConfig(); if (browserConfig.enabled) { // In container sandboxes (Docker/Podman/gVisor/LXC), Chrome is not // available inside the container. The browser agent can only work with // sessionMode "existing" (connecting to a host Chrome instance). const sandboxType = process.env['SANDBOX']; const isContainerSandbox = !!sandboxType && sandboxType !== 'sandbox-exec' && sandboxType !== 'sandbox:none'; const sessionMode = browserConfig.customConfig.sessionMode ?? 'persistent'; if (isContainerSandbox && sessionMode !== 'existing') { coreEvents.emitFeedback( 'info', 'Browser agent disabled in container sandbox. ' + 'To use it, set sessionMode to "existing" in settings and start Chrome ' + 'with --remote-debugging-port=9222 on the host.', ); } else { this.registerLocalAgent(BrowserAgentDefinition(this.config)); } } } private async refreshAgents( scope: AgentDefinition['kind'] | 'all' = 'all', ): Promise { this.loadBuiltInAgents(); await Promise.allSettled( Array.from(this.agents.values()).map(async (agent) => { if (scope === 'all' || agent.kind === scope) { await this.registerAgent(agent); } }), ); } /** * Registers an agent definition. If an agent with the same name exists, * it will be overwritten, respecting the precedence established by the * initialization order. */ protected async registerAgent( definition: AgentDefinition, errors?: string[], ): Promise { const existing = this.agents.get(definition.name); if (existing && existing !== definition) { coreEvents.emitFeedback( 'warning', `Duplicate agent name '${definition.name}' detected. ` + `The later definition will be ignored. ` + `Rename one of the agents to avoid this conflict.`, ); return; } if (definition.kind === 'local') { this.registerLocalAgent(definition); } else if (definition.kind === 'remote') { await this.registerRemoteAgent(definition, errors); } } /** * Registers a local agent definition synchronously. */ protected registerLocalAgent( definition: AgentDefinition, ): void { if (definition.kind !== 'local') { return; } // Basic validation if (!definition.name || !definition.description) { debugLogger.warn( `[AgentRegistry] Skipping invalid agent definition. Missing name or description.`, ); return; } this.allDefinitions.set(definition.name, definition); const settingsOverrides = this.config.getAgentsSettings().overrides?.[definition.name]; if (!this.isAgentEnabled(definition, settingsOverrides)) { 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}'`); } const mergedDefinition = this.applyOverrides(definition, settingsOverrides); this.agents.set(mergedDefinition.name, mergedDefinition); this.registerModelConfigs(mergedDefinition); this.addAgentPolicy(mergedDefinition); } private addAgentPolicy(definition: AgentDefinition): void { const policyEngine = this.config.getPolicyEngine(); if (!policyEngine) { return; } // If the user has explicitly defined a policy for this tool, respect it. // ignoreDynamic=true means we only check for rules NOT added by this registry. if (policyEngine.hasRuleForTool(definition.name, true)) { if (this.config.getDebugMode()) { debugLogger.log( `[AgentRegistry] User policy exists for '${definition.name}', skipping dynamic registration.`, ); } return; } // Only add override for remote agents. Local agents are handled by blanket allow. if (definition.kind === 'remote') { policyEngine.addRule({ toolName: AgentTool.Name, argsPattern: new RegExp(`"agent_name":\\s*"${definition.name}"`), decision: PolicyDecision.ASK_USER, priority: PRIORITY_SUBAGENT_TOOL + 0.1, // Higher priority to override blanket allow source: DYNAMIC_RULE_SOURCE, }); } } private isAgentEnabled( definition: AgentDefinition, overrides?: AgentOverride, ): boolean { const isExperimental = definition.experimental === true; let isEnabled = !isExperimental; if (overrides && overrides.enabled !== undefined) { isEnabled = overrides.enabled; } return isEnabled; } /** * Registers a remote agent definition asynchronously. * Provides robust error handling with user-friendly messages for: * - Agent card fetch failures (404, 401/403, network errors) * - Missing authentication configuration */ protected async registerRemoteAgent( definition: AgentDefinition, errors?: string[], ): Promise { if (definition.kind !== 'remote') { return; } // Basic validation // Remote agents can have an empty description initially as it will be populated from the AgentCard if (!definition.name) { debugLogger.warn( `[AgentRegistry] Skipping invalid agent definition. Missing name.`, ); return; } this.allDefinitions.set(definition.name, definition); const overrides = this.config.getAgentsSettings().overrides?.[definition.name]; if (!this.isAgentEnabled(definition, overrides)) { 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}'`); } const remoteDef = definition; // Capture the original description from the first registration if (remoteDef.originalDescription === undefined) { remoteDef.originalDescription = remoteDef.description; } // Load the remote A2A agent card and register. try { const clientManager = this.config.getA2AClientManager(); if (!clientManager) { debugLogger.warn( `[AgentRegistry] Skipping remote agent '${definition.name}': A2AClientManager is not available.`, ); return; } const targetUrl = getRemoteAgentTargetUrl(remoteDef); let authHandler: AuthenticationHandler | undefined; if (definition.auth) { const provider = await A2AAuthProviderFactory.create({ authConfig: definition.auth, agentName: definition.name, targetUrl, agentCardUrl: remoteDef.agentCardUrl, }); if (!provider) { throw new Error( `Failed to create auth provider for agent '${definition.name}'`, ); } authHandler = provider; } const agentCard = await clientManager.loadAgent( remoteDef.name, getAgentCardLoadOptions(remoteDef), authHandler, ); // Validate auth configuration against the agent card's security schemes. if (agentCard.securitySchemes) { const validation = A2AAuthProviderFactory.validateAuthConfig( definition.auth, agentCard.securitySchemes, ); if (!validation.valid && validation.diff) { const requiredAuth = A2AAuthProviderFactory.describeRequiredAuth( agentCard.securitySchemes, ); const authError = new AgentAuthConfigMissingError( definition.name, requiredAuth, validation.diff.missingConfig, ); coreEvents.emitFeedback( 'warning', `[${definition.name}] Agent requires authentication: ${requiredAuth}`, ); debugLogger.warn(`[AgentRegistry] ${authError.message}`); // Still register the agent — the user can fix config and retry. } } const userDescription = remoteDef.originalDescription; const agentDescription = agentCard.description; const descriptions: string[] = []; if (userDescription?.trim()) { descriptions.push(`User Description: ${userDescription.trim()}`); } if (agentDescription?.trim()) { descriptions.push(`Agent Description: ${agentDescription.trim()}`); } if (agentCard.skills && agentCard.skills.length > 0) { const skillsList = agentCard.skills .map( (skill: { name: string; description: string }) => `${skill.name}: ${skill.description || 'No description provided'}`, ) .join('\n'); descriptions.push(`Skills:\n${skillsList}`); } if (descriptions.length > 0) { definition.description = descriptions.join('\n'); } if (this.config.getDebugMode()) { debugLogger.log( `[AgentRegistry] Registered remote agent '${definition.name}' with card: ${definition.agentCardUrl ?? 'inline JSON'}`, ); } this.agents.set(definition.name, definition); this.addAgentPolicy(definition); } catch (e) { // Surface structured, user-friendly error messages for known failure modes. let msg: string; if (e instanceof A2AAgentError) { msg = `[${definition.name}] ${e.userMessage}`; } else { msg = `[${definition.name}] Failed to load remote agent: ${e instanceof Error ? e.message : String(e)}`; } errors?.push(msg); coreEvents.emitFeedback('error', msg); debugLogger.warn( `[AgentRegistry] Error loading A2A agent "${definition.name}":`, e, ); } } private applyOverrides( definition: LocalAgentDefinition, overrides?: AgentOverride, ): LocalAgentDefinition { if (definition.kind !== 'local' || !overrides) { return definition; } // Preserve lazy getters on the definition object by wrapping in a new object with getters const merged: LocalAgentDefinition = { get kind() { return definition.kind; }, get name() { return definition.name; }, get displayName() { return definition.displayName; }, get description() { return definition.description; }, get experimental() { return definition.experimental; }, get metadata() { return definition.metadata; }, get inputConfig() { return definition.inputConfig; }, get outputConfig() { return definition.outputConfig; }, get promptConfig() { return definition.promptConfig; }, get toolConfig() { return definition.toolConfig; }, get processOutput() { return definition.processOutput; }, get runConfig() { return overrides.runConfig ? { ...definition.runConfig, ...overrides.runConfig } : definition.runConfig; }, get modelConfig() { return overrides.modelConfig ? ModelConfigService.merge( definition.modelConfig, overrides.modelConfig, ) : definition.modelConfig; }, }; if (overrides.tools) { merged.toolConfig = { tools: overrides.tools, }; } if (overrides.mcpServers) { merged.mcpServers = { ...definition.mcpServers, ...overrides.mcpServers, }; } return merged; } private registerModelConfigs( definition: LocalAgentDefinition, ): void { const modelConfig = definition.modelConfig; let model = modelConfig.model; if (model === 'inherit') { model = this.config.getModel(); } const agentModelConfig: ModelConfig = { ...modelConfig, model, }; this.config.modelConfigService.registerRuntimeModelConfig( getModelConfigAlias(definition), { modelConfig: agentModelConfig, }, ); if (agentModelConfig.model && isAutoModel(agentModelConfig.model)) { this.config.modelConfigService.registerRuntimeModelOverride({ match: { overrideScope: definition.name, }, modelConfig: { generateContentConfig: agentModelConfig.generateContentConfig, }, }); } } /** * Retrieves an agent definition by name. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any getDefinition(name: string): AgentDefinition | undefined { return this.agents.get(name); } /** * Returns all active agent definitions. */ getAllDefinitions(): AgentDefinition[] { return Array.from(this.agents.values()); } /** * Returns a list of all registered agent names. */ getAllAgentNames(): string[] { return Array.from(this.agents.keys()); } /** * Returns a list of all discovered agent names, regardless of whether they are enabled. */ getAllDiscoveredAgentNames(): string[] { return Array.from(this.allDefinitions.keys()); } /** * Retrieves a discovered agent definition by name. */ getDiscoveredDefinition(name: string): AgentDefinition | undefined { return this.allDefinitions.get(name); } /** * Ensures that remote agents have a content-based hash for trust verification and change detection. */ private ensureRemoteAgentHash(agent: AgentDefinition): void { if (agent.kind !== 'remote') { return; } if (!agent.metadata) { agent.metadata = {}; } // To avoid a breaking change for existing users, we continue to use // the raw URL as the hash for URL-based remote agents. if (agent.agentCardUrl) { agent.metadata.hash = agent.agentCardUrl; } else if (agent.agentCardJson) { agent.metadata.hash = crypto .createHash('sha256') .update(agent.agentCardJson) .digest('hex'); } } }