diff --git a/packages/cli/src/ui/commands/agentsCommand.test.ts b/packages/cli/src/ui/commands/agentsCommand.test.ts index 1a5de99122..68874ffbe8 100644 --- a/packages/cli/src/ui/commands/agentsCommand.test.ts +++ b/packages/cli/src/ui/commands/agentsCommand.test.ts @@ -110,7 +110,15 @@ describe('agentsCommand', () => { }); it('should reload the agent registry when reload subcommand is called', async () => { - const reloadSpy = vi.fn().mockResolvedValue(undefined); + const reloadSpy = vi.fn().mockResolvedValue({ + totalLoaded: 3, + localCount: 2, + remoteCount: 1, + newAgents: ['new-agent'], + updatedAgents: ['updated-agent'], + deletedAgents: ['deleted-agent'], + errors: [], + }); mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ reload: reloadSpy, }); @@ -120,7 +128,10 @@ describe('agentsCommand', () => { ); expect(reloadCommand).toBeDefined(); - const result = await reloadCommand!.action!(mockContext, ''); + const result = (await reloadCommand!.action!(mockContext, '')) as { + type: 'message'; + content: string; + }; expect(reloadSpy).toHaveBeenCalled(); expect(mockContext.ui.addItem).toHaveBeenCalledWith( @@ -132,8 +143,42 @@ describe('agentsCommand', () => { expect(result).toEqual({ type: 'message', messageType: 'info', - content: 'Agents reloaded successfully', + content: expect.stringContaining('Agents reloaded successfully:'), }); + expect(result.content).toContain('- Total: 3 (2 local, 1 remote)'); + expect(result.content).toContain('- New: new-agent'); + expect(result.content).toContain('- Updated: updated-agent'); + expect(result.content).toContain('- Deleted: deleted-agent'); + expect(result.content).toContain( + 'Run /agents list to see all available agents.', + ); + }); + + it('should show "reloaded with errors" if errors occurred during reload', async () => { + const reloadSpy = vi.fn().mockResolvedValue({ + totalLoaded: 1, + localCount: 1, + remoteCount: 0, + newAgents: [], + updatedAgents: [], + deletedAgents: [], + errors: ['Some error'], + }); + mockConfig.getAgentRegistry = vi.fn().mockReturnValue({ + reload: reloadSpy, + }); + + const reloadCommand = agentsCommand.subCommands?.find( + (cmd) => cmd.name === 'reload', + ); + + const result = (await reloadCommand!.action!(mockContext, '')) as { + type: 'message'; + content: string; + }; + + expect(result.content).toContain('Agents reloaded with errors:'); + expect(result.content).toContain('- Errors: 1 encountered during reload'); }); it('should show an error if agent registry is not available during reload', async () => { diff --git a/packages/cli/src/ui/commands/agentsCommand.ts b/packages/cli/src/ui/commands/agentsCommand.ts index d1b582d673..4af6564979 100644 --- a/packages/cli/src/ui/commands/agentsCommand.ts +++ b/packages/cli/src/ui/commands/agentsCommand.ts @@ -346,12 +346,33 @@ const agentsReloadCommand: SlashCommand = { text: 'Reloading agent registry...', }); - await agentRegistry.reload(); + const summary = await agentRegistry.reload(); + + let content = + summary.errors.length > 0 + ? 'Agents reloaded with errors:' + : 'Agents reloaded successfully:'; + content += `\n- Total: ${summary.totalLoaded} (${summary.localCount} local, ${summary.remoteCount} remote)`; + + if (summary.newAgents.length > 0) { + content += `\n- New: ${summary.newAgents.join(', ')}`; + } + if (summary.updatedAgents.length > 0) { + content += `\n- Updated: ${summary.updatedAgents.join(', ')}`; + } + if (summary.deletedAgents.length > 0) { + content += `\n- Deleted: ${summary.deletedAgents.join(', ')}`; + } + if (summary.errors.length > 0) { + content += `\n- Errors: ${summary.errors.length} encountered during reload`; + } + + content += '\n\nRun /agents list to see all available agents.'; return { type: 'message', messageType: 'info', - content: 'Agents reloaded successfully', + content, }; }, }; diff --git a/packages/core/src/agents/registry.test.ts b/packages/core/src/agents/registry.test.ts index 3d45be1f94..7618440957 100644 --- a/packages/core/src/agents/registry.test.ts +++ b/packages/core/src/agents/registry.test.ts @@ -459,7 +459,7 @@ describe('AgentRegistry', () => { await registry.initialize(); - // Verify ackService was called with the URL, not the file hash + // Verify ackService was called with the raw URL to avoid breaking changes expect(ackService.isAcknowledged).toHaveBeenCalledWith( expect.anything(), 'RemoteAgent', @@ -467,7 +467,6 @@ describe('AgentRegistry', () => { ); // Also verify that the agent's metadata was updated to use the URL as hash - // Use getDefinition because registerAgent might have been called expect(registry.getDefinition('RemoteAgent')?.metadata?.hash).toBe( 'https://example.com/card', ); diff --git a/packages/core/src/agents/registry.ts b/packages/core/src/agents/registry.ts index 32aee9d2c5..b9d434e4c7 100644 --- a/packages/core/src/agents/registry.ts +++ b/packages/core/src/agents/registry.ts @@ -8,7 +8,11 @@ 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, LocalAgentDefinition } from './types.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'; @@ -80,13 +84,53 @@ export class AgentRegistry { /** * Clears the current registry and re-scans for agents. */ - async reload(): Promise { + async reload(): Promise { + const previousAgents = new Map(this.agents); + const reloadErrors: string[] = []; + this.config.getA2AClientManager()?.clearCache(); await this.config.reloadAgents(); - this.agents.clear(); - this.allDefinitions.clear(); - await this.loadAgents(); + 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, + }; } /** @@ -113,7 +157,7 @@ export class AgentRegistry { coreEvents.off(CoreEvent.ModelChanged, this.onModelChanged); } - private async loadAgents(): Promise { + private async loadAgents(errors?: string[]): Promise { this.agents.clear(); this.allDefinitions.clear(); this.loadBuiltInAgents(); @@ -132,21 +176,20 @@ export class AgentRegistry { debugLogger.warn( `[AgentRegistry] Error loading user agent: ${error.message}`, ); - coreEvents.emitFeedback('error', `Agent loading error: ${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 { - await this.registerAgent(agent); + this.ensureRemoteAgentHash(agent); + await this.registerAgent(agent, errors); } catch (e) { - debugLogger.warn( - `[AgentRegistry] Error registering user agent "${agent.name}":`, - e, - ); - coreEvents.emitFeedback( - 'error', - `Error registering user agent "${agent.name}": ${e instanceof Error ? e.message : String(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); } }), ); @@ -159,10 +202,9 @@ export class AgentRegistry { const projectAgentsDir = this.config.storage.getProjectAgentsDir(); const projectAgents = await loadAgentsFromDirectory(projectAgentsDir); for (const error of projectAgents.errors) { - coreEvents.emitFeedback( - 'error', - `Agent loading error: ${error.message}`, - ); + const msg = `Agent loading error: ${error.message}`; + errors?.push(msg); + coreEvents.emitFeedback('error', msg); } const ackService = this.config.getAcknowledgedAgentsService(); @@ -171,21 +213,7 @@ export class AgentRegistry { const agentsToRegister: AgentDefinition[] = []; for (const agent of projectAgents.agents) { - // If it's a remote agent, use the agentCardUrl as the hash. - // This allows multiple remote agents in a single file to be tracked independently. - if (agent.kind === 'remote') { - if (!agent.metadata) { - agent.metadata = {}; - } - agent.metadata.hash = - agent.agentCardUrl ?? - (agent.agentCardJson - ? crypto - .createHash('sha256') - .update(agent.agentCardJson) - .digest('hex') - : undefined); - } + this.ensureRemoteAgentHash(agent); if (!agent.metadata?.hash) { agentsToRegister.push(agent); @@ -212,16 +240,12 @@ export class AgentRegistry { await Promise.allSettled( agentsToRegister.map(async (agent) => { try { - await this.registerAgent(agent); + await this.registerAgent(agent, errors); } catch (e) { - debugLogger.warn( - `[AgentRegistry] Error registering project agent "${agent.name}":`, - e, - ); - coreEvents.emitFeedback( - 'error', - `Error registering project agent "${agent.name}": ${e instanceof Error ? e.message : String(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); } }), ); @@ -238,16 +262,12 @@ export class AgentRegistry { await Promise.allSettled( extension.agents.map(async (agent) => { try { - await this.registerAgent(agent); + await this.registerAgent(agent, errors); } catch (e) { - debugLogger.warn( - `[AgentRegistry] Error registering extension agent "${agent.name}":`, - e, - ); - coreEvents.emitFeedback( - 'error', - `Error registering extension agent "${agent.name}": ${e instanceof Error ? e.message : String(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); } }), ); @@ -314,11 +334,12 @@ export class AgentRegistry { */ protected async registerAgent( definition: AgentDefinition, + errors?: string[], ): Promise { if (definition.kind === 'local') { this.registerLocalAgent(definition); } else if (definition.kind === 'remote') { - await this.registerRemoteAgent(definition); + await this.registerRemoteAgent(definition, errors); } } @@ -416,6 +437,7 @@ export class AgentRegistry { */ protected async registerRemoteAgent( definition: AgentDefinition, + errors?: string[], ): Promise { if (definition.kind !== 'remote') { return; @@ -544,17 +566,14 @@ export class AgentRegistry { this.addAgentPolicy(definition); } catch (e) { // Surface structured, user-friendly error messages for known failure modes. + let msg: string; if (e instanceof A2AAgentError) { - coreEvents.emitFeedback( - 'error', - `[${definition.name}] ${e.userMessage}`, - ); + msg = `[${definition.name}] ${e.userMessage}`; } else { - coreEvents.emitFeedback( - 'error', - `[${definition.name}] Failed to load remote agent: ${e instanceof Error ? e.message : String(e)}`, - ); + 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, @@ -704,4 +723,28 @@ export class AgentRegistry { 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'); + } + } } diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts index 0774df6dbb..bfca8b81d6 100644 --- a/packages/core/src/agents/types.ts +++ b/packages/core/src/agents/types.ts @@ -369,3 +369,16 @@ export interface RunConfig { */ maxTurns?: number; } + +/** + * Summary of an agent reload operation. + */ +export interface AgentReloadSummary { + totalLoaded: number; + localCount: number; + remoteCount: number; + newAgents: string[]; + updatedAgents: string[]; + deletedAgents: string[]; + errors: string[]; +}