From 2271bbb339f88e2e014a53ee3130ab8bb14fd269 Mon Sep 17 00:00:00 2001 From: Christian Gunderman Date: Mon, 26 Jan 2026 19:49:32 +0000 Subject: [PATCH] feat(agents): implement first-run experience for project-level sub-agents (#17266) --- packages/cli/src/test-utils/render.tsx | 1 + packages/cli/src/ui/AppContainer.tsx | 39 +++- .../cli/src/ui/components/DialogManager.tsx | 9 + .../components/NewAgentsNotification.test.tsx | 57 ++++++ .../ui/components/NewAgentsNotification.tsx | 96 ++++++++++ .../NewAgentsNotification.test.tsx.snap | 43 +++++ .../cli/src/ui/contexts/UIActionsContext.tsx | 2 + .../cli/src/ui/contexts/UIStateContext.tsx | 1 + .../src/agents/acknowledgedAgents.test.ts | 97 ++++++++++ .../core/src/agents/acknowledgedAgents.ts | 85 +++++++++ packages/core/src/agents/agentLoader.ts | 36 ++-- packages/core/src/agents/registry.test.ts | 53 ++++++ packages/core/src/agents/registry.ts | 59 +++++- .../agents/registry_acknowledgement.test.ts | 169 ++++++++++++++++++ packages/core/src/agents/types.ts | 4 + packages/core/src/config/config.ts | 7 + packages/core/src/config/storage.ts | 8 + packages/core/src/utils/events.ts | 18 ++ 18 files changed, 769 insertions(+), 15 deletions(-) create mode 100644 packages/cli/src/ui/components/NewAgentsNotification.test.tsx create mode 100644 packages/cli/src/ui/components/NewAgentsNotification.tsx create mode 100644 packages/cli/src/ui/components/__snapshots__/NewAgentsNotification.test.tsx.snap create mode 100644 packages/core/src/agents/acknowledgedAgents.test.ts create mode 100644 packages/core/src/agents/acknowledgedAgents.ts create mode 100644 packages/core/src/agents/registry_acknowledgement.test.ts diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index a7f90aecfe..717aa668d1 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -198,6 +198,7 @@ const mockUIActions: UIActions = { setEmbeddedShellFocused: vi.fn(), setAuthContext: vi.fn(), handleRestart: vi.fn(), + handleNewAgentsSelect: vi.fn(), }; export const renderWithProviders = ( diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 4f10e10645..45ccd33ad0 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -63,6 +63,7 @@ import { SessionStartSource, SessionEndReason, generateSummary, + type AgentsDiscoveredPayload, ChangeAuthRequestedError, } from '@google/gemini-cli-core'; import { validateAuthMethod } from '../config/auth.js'; @@ -133,6 +134,7 @@ import { QUEUE_ERROR_DISPLAY_DURATION_MS, } from './constants.js'; import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js'; +import { NewAgentsChoice } from './components/NewAgentsNotification.js'; import { isSlashCommand } from './utils/commandUtils.js'; function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { @@ -218,6 +220,8 @@ export const AppContainer = (props: AppContainerProps) => { null, ); + const [newAgents, setNewAgents] = useState(null); + const [defaultBannerText, setDefaultBannerText] = useState(''); const [warningBannerText, setWarningBannerText] = useState(''); const [bannerVisible, setBannerVisible] = useState(true); @@ -414,14 +418,20 @@ export const AppContainer = (props: AppContainerProps) => { setAdminSettingsChanged(true); }; + const handleAgentsDiscovered = (payload: AgentsDiscoveredPayload) => { + setNewAgents(payload.agents); + }; + coreEvents.on(CoreEvent.SettingsChanged, handleSettingsChanged); coreEvents.on(CoreEvent.AdminSettingsChanged, handleAdminSettingsChanged); + coreEvents.on(CoreEvent.AgentsDiscovered, handleAgentsDiscovered); return () => { coreEvents.off(CoreEvent.SettingsChanged, handleSettingsChanged); coreEvents.off( CoreEvent.AdminSettingsChanged, handleAdminSettingsChanged, ); + coreEvents.off(CoreEvent.AgentsDiscovered, handleAgentsDiscovered); }; }, []); @@ -1564,8 +1574,8 @@ Logging in with Google... Restarting Gemini CLI to continue. !!proQuotaRequest || !!validationRequest || isSessionBrowserOpen || - isAuthDialogOpen || - authState === AuthState.AwaitingApiKeyInput; + authState === AuthState.AwaitingApiKeyInput || + !!newAgents; const pendingHistoryItems = useMemo( () => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems], @@ -1728,6 +1738,7 @@ Logging in with Google... Restarting Gemini CLI to continue. terminalBackgroundColor: config.getTerminalBackground(), settingsNonce, adminSettingsChanged, + newAgents, }), [ isThemeDialogOpen, @@ -1828,6 +1839,7 @@ Logging in with Google... Restarting Gemini CLI to continue. config, settingsNonce, adminSettingsChanged, + newAgents, ], ); @@ -1879,6 +1891,26 @@ Logging in with Google... Restarting Gemini CLI to continue. await runExitCleanup(); process.exit(RELAUNCH_EXIT_CODE); }, + handleNewAgentsSelect: async (choice: NewAgentsChoice) => { + if (newAgents && choice === NewAgentsChoice.ACKNOWLEDGE) { + const registry = config.getAgentRegistry(); + try { + await Promise.all( + newAgents.map((agent) => registry.acknowledgeAgent(agent)), + ); + } catch (error) { + debugLogger.error('Failed to acknowledge agents:', error); + historyManager.addItem( + { + type: MessageType.ERROR, + text: `Failed to acknowledge agents: ${getErrorMessage(error)}`, + }, + Date.now(), + ); + } + } + setNewAgents(null); + }, }), [ handleThemeSelect, @@ -1918,6 +1950,9 @@ Logging in with Google... Restarting Gemini CLI to continue. setBannerVisible, setEmbeddedShellFocused, setAuthContext, + newAgents, + config, + historyManager, ], ); diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 5d66927487..305f2333f1 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -32,6 +32,7 @@ import process from 'node:process'; import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js'; import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js'; +import { NewAgentsNotification } from './NewAgentsNotification.js'; import { AgentConfigDialog } from './AgentConfigDialog.js'; interface DialogManagerProps { @@ -58,6 +59,14 @@ export const DialogManager = ({ if (uiState.showIdeRestartPrompt) { return ; } + if (uiState.newAgents) { + return ( + + ); + } if (uiState.proQuotaRequest) { return ( { + const mockAgents = [ + { + name: 'Agent A', + description: 'Description A', + kind: 'remote' as const, + agentCardUrl: '', + inputConfig: { inputSchema: {} }, + }, + { + name: 'Agent B', + description: 'Description B', + kind: 'remote' as const, + agentCardUrl: '', + inputConfig: { inputSchema: {} }, + }, + ]; + const onSelect = vi.fn(); + + it('renders agent list', () => { + const { lastFrame, unmount } = render( + , + ); + + const frame = lastFrame(); + expect(frame).toMatchSnapshot(); + unmount(); + }); + + it('truncates list if more than 5 agents', () => { + const manyAgents = Array.from({ length: 7 }, (_, i) => ({ + name: `Agent ${i}`, + description: `Description ${i}`, + kind: 'remote' as const, + agentCardUrl: '', + inputConfig: { inputSchema: {} }, + })); + + const { lastFrame, unmount } = render( + , + ); + + const frame = lastFrame(); + expect(frame).toMatchSnapshot(); + unmount(); + }); +}); diff --git a/packages/cli/src/ui/components/NewAgentsNotification.tsx b/packages/cli/src/ui/components/NewAgentsNotification.tsx new file mode 100644 index 0000000000..05edae484c --- /dev/null +++ b/packages/cli/src/ui/components/NewAgentsNotification.tsx @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import { type AgentDefinition } from '@google/gemini-cli-core'; +import { theme } from '../semantic-colors.js'; +import { + RadioButtonSelect, + type RadioSelectItem, +} from './shared/RadioButtonSelect.js'; + +export enum NewAgentsChoice { + ACKNOWLEDGE = 'acknowledge', + IGNORE = 'ignore', +} + +interface NewAgentsNotificationProps { + agents: AgentDefinition[]; + onSelect: (choice: NewAgentsChoice) => void; +} + +export const NewAgentsNotification = ({ + agents, + onSelect, +}: NewAgentsNotificationProps) => { + const options: Array> = [ + { + label: 'Acknowledge and Enable', + value: NewAgentsChoice.ACKNOWLEDGE, + key: 'acknowledge', + }, + { + label: 'Do not enable (Ask again next time)', + value: NewAgentsChoice.IGNORE, + key: 'ignore', + }, + ]; + + // Limit display to 5 agents to avoid overflow, show count for rest + const MAX_DISPLAYED_AGENTS = 5; + const displayAgents = agents.slice(0, MAX_DISPLAYED_AGENTS); + const remaining = agents.length - MAX_DISPLAYED_AGENTS; + + return ( + + + + + New Agents Discovered + + + The following agents were found in this project. Please review them: + + + {displayAgents.map((agent) => ( + + + + - {agent.name}:{' '} + + + {agent.description} + + ))} + {remaining > 0 && ( + + ... and {remaining} more. + + )} + + + + + + + ); +}; diff --git a/packages/cli/src/ui/components/__snapshots__/NewAgentsNotification.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/NewAgentsNotification.test.tsx.snap new file mode 100644 index 0000000000..438d51e1e3 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/NewAgentsNotification.test.tsx.snap @@ -0,0 +1,43 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`NewAgentsNotification > renders agent list 1`] = ` +" ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ New Agents Discovered │ + │ The following agents were found in this project. Please review them: │ + │ │ + │ ┌────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ │ + │ │ │ │ + │ │ - Agent A: Description A │ │ + │ │ - Agent B: Description B │ │ + │ │ │ │ + │ └────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ │ + │ │ + │ ● 1. Acknowledge and Enable │ + │ 2. Do not enable (Ask again next time) │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; + +exports[`NewAgentsNotification > truncates list if more than 5 agents 1`] = ` +" ╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ │ + │ New Agents Discovered │ + │ The following agents were found in this project. Please review them: │ + │ │ + │ ┌────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ │ + │ │ │ │ + │ │ - Agent 0: Description 0 │ │ + │ │ - Agent 1: Description 1 │ │ + │ │ - Agent 2: Description 2 │ │ + │ │ - Agent 3: Description 3 │ │ + │ │ - Agent 4: Description 4 │ │ + │ │ ... and 2 more. │ │ + │ │ │ │ + │ └────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ │ + │ │ + │ ● 1. Acknowledge and Enable │ + │ 2. Do not enable (Ask again next time) │ + │ │ + ╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯" +`; diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index c8abf33236..4eb8584ae3 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -17,6 +17,7 @@ import { type LoadableSettingScope } from '../../config/settings.js'; import type { AuthState } from '../types.js'; import { type PermissionsDialogProps } from '../components/PermissionsModifyTrustDialog.js'; import type { SessionInfo } from '../../utils/sessionUtils.js'; +import { type NewAgentsChoice } from '../components/NewAgentsNotification.js'; export interface UIActions { handleThemeSelect: (themeName: string, scope: LoadableSettingScope) => void; @@ -69,6 +70,7 @@ export interface UIActions { setEmbeddedShellFocused: (value: boolean) => void; setAuthContext: (context: { requiresRestart?: boolean }) => void; handleRestart: () => void; + handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise; } export const UIActionsContext = createContext(null); diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index fea13285b1..6d10d76bda 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -155,6 +155,7 @@ export interface UIState { terminalBackgroundColor: TerminalBackgroundColor; settingsNonce: number; adminSettingsChanged: boolean; + newAgents: AgentDefinition[] | null; } export const UIStateContext = createContext(null); diff --git a/packages/core/src/agents/acknowledgedAgents.test.ts b/packages/core/src/agents/acknowledgedAgents.test.ts new file mode 100644 index 0000000000..f6e45360db --- /dev/null +++ b/packages/core/src/agents/acknowledgedAgents.test.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { AcknowledgedAgentsService } from './acknowledgedAgents.js'; +import { Storage } from '../config/storage.js'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +describe('AcknowledgedAgentsService', () => { + let tempDir: string; + let originalGeminiCliHome: string | undefined; + + beforeEach(async () => { + // Create a unique temp directory for each test + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-cli-test-')); + + // Override GEMINI_CLI_HOME to point to the temp directory + originalGeminiCliHome = process.env['GEMINI_CLI_HOME']; + process.env['GEMINI_CLI_HOME'] = tempDir; + }); + + afterEach(async () => { + // Restore environment variable + if (originalGeminiCliHome) { + process.env['GEMINI_CLI_HOME'] = originalGeminiCliHome; + } else { + delete process.env['GEMINI_CLI_HOME']; + } + + // Clean up temp directory + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('should acknowledge an agent and save to disk', async () => { + const service = new AcknowledgedAgentsService(); + const ackPath = Storage.getAcknowledgedAgentsPath(); + + await service.acknowledge('/project', 'AgentA', 'hash1'); + + // Verify file exists and content + const content = await fs.readFile(ackPath, 'utf-8'); + expect(content).toContain('"AgentA": "hash1"'); + }); + + it('should return true for acknowledged agent', async () => { + const service = new AcknowledgedAgentsService(); + + await service.acknowledge('/project', 'AgentA', 'hash1'); + + expect(await service.isAcknowledged('/project', 'AgentA', 'hash1')).toBe( + true, + ); + expect(await service.isAcknowledged('/project', 'AgentA', 'hash2')).toBe( + false, + ); + expect(await service.isAcknowledged('/project', 'AgentB', 'hash1')).toBe( + false, + ); + }); + + it('should load acknowledged agents from disk', async () => { + const ackPath = Storage.getAcknowledgedAgentsPath(); + const data = { + '/project': { + AgentLoaded: 'hashLoaded', + }, + }; + + // Ensure directory exists + await fs.mkdir(path.dirname(ackPath), { recursive: true }); + await fs.writeFile(ackPath, JSON.stringify(data), 'utf-8'); + + const service = new AcknowledgedAgentsService(); + + expect( + await service.isAcknowledged('/project', 'AgentLoaded', 'hashLoaded'), + ).toBe(true); + }); + + it('should handle load errors gracefully', async () => { + // Create a directory where the file should be to cause a read error (EISDIR) + const ackPath = Storage.getAcknowledgedAgentsPath(); + await fs.mkdir(ackPath, { recursive: true }); + + const service = new AcknowledgedAgentsService(); + + // Should not throw, and treated as empty + expect(await service.isAcknowledged('/project', 'Agent', 'hash')).toBe( + false, + ); + }); +}); diff --git a/packages/core/src/agents/acknowledgedAgents.ts b/packages/core/src/agents/acknowledgedAgents.ts new file mode 100644 index 0000000000..230b62443a --- /dev/null +++ b/packages/core/src/agents/acknowledgedAgents.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { Storage } from '../config/storage.js'; +import { debugLogger } from '../utils/debugLogger.js'; +import { getErrorMessage, isNodeError } from '../utils/errors.js'; + +export interface AcknowledgedAgentsMap { + // Project Path -> Agent Name -> Agent Hash + [projectPath: string]: { + [agentName: string]: string; + }; +} + +export class AcknowledgedAgentsService { + private acknowledgedAgents: AcknowledgedAgentsMap = {}; + private loaded = false; + + async load(): Promise { + if (this.loaded) return; + + const filePath = Storage.getAcknowledgedAgentsPath(); + try { + const content = await fs.readFile(filePath, 'utf-8'); + this.acknowledgedAgents = JSON.parse(content); + } catch (error: unknown) { + if (!isNodeError(error) || error.code !== 'ENOENT') { + debugLogger.error( + 'Failed to load acknowledged agents:', + getErrorMessage(error), + ); + } + // If file doesn't exist or there's a parsing error, fallback to empty + this.acknowledgedAgents = {}; + } + this.loaded = true; + } + + async save(): Promise { + const filePath = Storage.getAcknowledgedAgentsPath(); + try { + const dir = path.dirname(filePath); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile( + filePath, + JSON.stringify(this.acknowledgedAgents, null, 2), + 'utf-8', + ); + } catch (error) { + debugLogger.error( + 'Failed to save acknowledged agents:', + getErrorMessage(error), + ); + } + } + + async isAcknowledged( + projectPath: string, + agentName: string, + hash: string, + ): Promise { + await this.load(); + const projectAgents = this.acknowledgedAgents[projectPath]; + if (!projectAgents) return false; + return projectAgents[agentName] === hash; + } + + async acknowledge( + projectPath: string, + agentName: string, + hash: string, + ): Promise { + await this.load(); + if (!this.acknowledgedAgents[projectPath]) { + this.acknowledgedAgents[projectPath] = {}; + } + this.acknowledgedAgents[projectPath][agentName] = hash; + await this.save(); + } +} diff --git a/packages/core/src/agents/agentLoader.ts b/packages/core/src/agents/agentLoader.ts index 385d1e9b59..1679b52fb3 100644 --- a/packages/core/src/agents/agentLoader.ts +++ b/packages/core/src/agents/agentLoader.ts @@ -8,10 +8,12 @@ import yaml from 'js-yaml'; import * as fs from 'node:fs/promises'; import { type Dirent } from 'node:fs'; import * as path from 'node:path'; +import * as crypto from 'node:crypto'; import { z } from 'zod'; import type { AgentDefinition } from './types.js'; import { isValidToolName } from '../tools/tool-names.js'; import { FRONTMATTER_REGEX } from '../skills/skillLoader.js'; +import { getErrorMessage } from '../utils/errors.js'; /** * DTO for Markdown parsing - represents the structure from frontmatter. @@ -139,24 +141,30 @@ function formatZodError(error: z.ZodError, context: string): string { * Parses and validates an agent Markdown file with frontmatter. * * @param filePath Path to the Markdown file. + * @param content Optional pre-loaded content of the file. * @returns An array containing the single parsed agent definition. * @throws AgentLoadError if parsing or validation fails. */ export async function parseAgentMarkdown( filePath: string, + content?: string, ): Promise { - let content: string; - try { - content = await fs.readFile(filePath, 'utf-8'); - } catch (error) { - throw new AgentLoadError( - filePath, - `Could not read file: ${(error as Error).message}`, - ); + let fileContent: string; + if (content !== undefined) { + fileContent = content; + } else { + try { + fileContent = await fs.readFile(filePath, 'utf-8'); + } catch (error) { + throw new AgentLoadError( + filePath, + `Could not read file: ${getErrorMessage(error)}`, + ); + } } // Split frontmatter and body - const match = content.match(FRONTMATTER_REGEX); + const match = fileContent.match(FRONTMATTER_REGEX); if (!match) { throw new AgentLoadError( filePath, @@ -229,10 +237,12 @@ export async function parseAgentMarkdown( * Converts a FrontmatterAgentDefinition DTO to the internal AgentDefinition structure. * * @param markdown The parsed Markdown/Frontmatter definition. + * @param metadata Optional metadata including hash and file path. * @returns The internal AgentDefinition. */ export function markdownToAgentDefinition( markdown: FrontmatterAgentDefinition, + metadata?: { hash?: string; filePath?: string }, ): AgentDefinition { const inputConfig = { inputSchema: { @@ -256,6 +266,7 @@ export function markdownToAgentDefinition( displayName: markdown.display_name, agentCardUrl: markdown.agent_card_url, inputConfig, + metadata, }; } @@ -288,6 +299,7 @@ export function markdownToAgentDefinition( } : undefined, inputConfig, + metadata, }; } @@ -334,9 +346,11 @@ export async function loadAgentsFromDirectory( for (const entry of files) { const filePath = path.join(dir, entry.name); try { - const agentDefs = await parseAgentMarkdown(filePath); + const content = await fs.readFile(filePath, 'utf-8'); + const hash = crypto.createHash('sha256').update(content).digest('hex'); + const agentDefs = await parseAgentMarkdown(filePath, content); for (const def of agentDefs) { - const agent = markdownToAgentDefinition(def); + const agent = markdownToAgentDefinition(def, { hash, filePath }); result.agents.push(agent); } } catch (error) { diff --git a/packages/core/src/agents/registry.test.ts b/packages/core/src/agents/registry.test.ts index e55f4214aa..9eb43f357b 100644 --- a/packages/core/src/agents/registry.test.ts +++ b/packages/core/src/agents/registry.test.ts @@ -25,6 +25,7 @@ import { SimpleExtensionLoader } from '../utils/extensionLoader.js'; import type { ConfigParameters } from '../config/config.js'; import type { ToolRegistry } from '../tools/tool-registry.js'; import { ThinkingLevel } from '@google/genai'; +import type { AcknowledgedAgentsService } from './acknowledgedAgents.js'; vi.mock('./agentLoader.js', () => ({ loadAgentsFromDirectory: vi @@ -401,6 +402,58 @@ describe('AgentRegistry', () => { expect(registry.getDefinition('extension-agent')).toBeUndefined(); }); + + it('should use agentCardUrl as hash for acknowledgement of remote agents', async () => { + mockConfig = makeMockedConfig({ enableAgents: true }); + // Trust the folder so it attempts to load project agents + vi.spyOn(mockConfig, 'isTrustedFolder').mockReturnValue(true); + vi.spyOn(mockConfig, 'getFolderTrust').mockReturnValue(true); + + const registry = new TestableAgentRegistry(mockConfig); + + const remoteAgent: AgentDefinition = { + kind: 'remote', + name: 'RemoteAgent', + description: 'A remote agent', + agentCardUrl: 'https://example.com/card', + inputConfig: { inputSchema: { type: 'object' } }, + metadata: { hash: 'file-hash', filePath: 'path/to/file.md' }, + }; + + vi.mocked(tomlLoader.loadAgentsFromDirectory).mockResolvedValue({ + agents: [remoteAgent], + errors: [], + }); + + const ackService = { + isAcknowledged: vi.fn().mockResolvedValue(true), + acknowledge: vi.fn(), + }; + vi.spyOn(mockConfig, 'getAcknowledgedAgentsService').mockReturnValue( + ackService as unknown as AcknowledgedAgentsService, + ); + + // Mock A2AClientManager to avoid network calls + vi.mocked(A2AClientManager.getInstance).mockReturnValue({ + loadAgent: vi.fn().mockResolvedValue({ name: 'RemoteAgent' }), + clearCache: vi.fn(), + } as unknown as A2AClientManager); + + await registry.initialize(); + + // Verify ackService was called with the URL, not the file hash + expect(ackService.isAcknowledged).toHaveBeenCalledWith( + expect.anything(), + 'RemoteAgent', + 'https://example.com/card', + ); + + // 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', + ); + }); }); describe('registration logic', () => { diff --git a/packages/core/src/agents/registry.ts b/packages/core/src/agents/registry.ts index cc68156344..cc91ffeeed 100644 --- a/packages/core/src/agents/registry.ts +++ b/packages/core/src/agents/registry.ts @@ -5,7 +5,7 @@ */ import { Storage } from '../config/storage.js'; -import { coreEvents, CoreEvent } from '../utils/events.js'; +import { CoreEvent, coreEvents } from '../utils/events.js'; import type { AgentOverride, Config } from '../config/config.js'; import type { AgentDefinition, LocalAgentDefinition } from './types.js'; import { loadAgentsFromDirectory } from './agentLoader.js'; @@ -73,6 +73,23 @@ export class AgentRegistry { coreEvents.emitAgentsRefreshed(); } + /** + * 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. */ @@ -115,8 +132,46 @@ export class AgentRegistry { `Agent loading error: ${error.message}`, ); } + + const ackService = this.config.getAcknowledgedAgentsService(); + const projectRoot = this.config.getProjectRoot(); + const unacknowledgedAgents: AgentDefinition[] = []; + 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; + } + + 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( - projectAgents.agents.map((agent) => this.registerAgent(agent)), + agentsToRegister.map((agent) => this.registerAgent(agent)), ); } else { coreEvents.emitFeedback( diff --git a/packages/core/src/agents/registry_acknowledgement.test.ts b/packages/core/src/agents/registry_acknowledgement.test.ts new file mode 100644 index 0000000000..5ac563091d --- /dev/null +++ b/packages/core/src/agents/registry_acknowledgement.test.ts @@ -0,0 +1,169 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { AgentRegistry } from './registry.js'; +import { makeFakeConfig } from '../test-utils/config.js'; +import type { AgentDefinition } from './types.js'; +import { coreEvents } from '../utils/events.js'; +import * as tomlLoader from './agentLoader.js'; +import { type Config } from '../config/config.js'; +import { AcknowledgedAgentsService } from './acknowledgedAgents.js'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +// Mock dependencies +vi.mock('./agentLoader.js', () => ({ + loadAgentsFromDirectory: vi.fn(), +})); + +const MOCK_AGENT_WITH_HASH: AgentDefinition = { + kind: 'local', + name: 'ProjectAgent', + description: 'Project Agent Desc', + inputConfig: { inputSchema: { type: 'object' } }, + modelConfig: { + model: 'test', + generateContentConfig: { thinkingConfig: { includeThoughts: true } }, + }, + runConfig: { maxTimeMinutes: 1 }, + promptConfig: { systemPrompt: 'test' }, + metadata: { + hash: 'hash123', + filePath: '/project/agent.md', + }, +}; + +describe('AgentRegistry Acknowledgement', () => { + let registry: AgentRegistry; + let config: Config; + let tempDir: string; + let originalGeminiCliHome: string | undefined; + let ackService: AcknowledgedAgentsService; + + beforeEach(async () => { + // Create a unique temp directory for each test + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-cli-test-')); + + // Override GEMINI_CLI_HOME to point to the temp directory + originalGeminiCliHome = process.env['GEMINI_CLI_HOME']; + process.env['GEMINI_CLI_HOME'] = tempDir; + + ackService = new AcknowledgedAgentsService(); + + config = makeFakeConfig({ + folderTrust: true, + trustedFolder: true, + }); + // Ensure we are in trusted folder mode for project agents to load + vi.spyOn(config, 'isTrustedFolder').mockReturnValue(true); + vi.spyOn(config, 'getFolderTrust').mockReturnValue(true); + vi.spyOn(config, 'getProjectRoot').mockReturnValue('/project'); + vi.spyOn(config, 'getAcknowledgedAgentsService').mockReturnValue( + ackService, + ); + + // We cannot easily spy on storage.getProjectAgentsDir if it's a property/getter unless we cast to any or it's a method + // Assuming it's a method on Storage class + vi.spyOn(config.storage, 'getProjectAgentsDir').mockReturnValue( + '/project/.gemini/agents', + ); + vi.spyOn(config, 'isAgentsEnabled').mockReturnValue(true); + + registry = new AgentRegistry(config); + + vi.mocked(tomlLoader.loadAgentsFromDirectory).mockImplementation( + async (dir) => { + if (dir === '/project/.gemini/agents') { + return { + agents: [MOCK_AGENT_WITH_HASH], + errors: [], + }; + } + return { agents: [], errors: [] }; + }, + ); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + + // Restore environment variable + if (originalGeminiCliHome) { + process.env['GEMINI_CLI_HOME'] = originalGeminiCliHome; + } else { + delete process.env['GEMINI_CLI_HOME']; + } + + // Clean up temp directory + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('should not register unacknowledged project agents and emit event', async () => { + const emitSpy = vi.spyOn(coreEvents, 'emitAgentsDiscovered'); + + await registry.initialize(); + + expect(registry.getDefinition('ProjectAgent')).toBeUndefined(); + expect(emitSpy).toHaveBeenCalledWith([MOCK_AGENT_WITH_HASH]); + }); + + it('should register acknowledged project agents', async () => { + // Acknowledge the agent explicitly + await ackService.acknowledge('/project', 'ProjectAgent', 'hash123'); + + vi.mocked(tomlLoader.loadAgentsFromDirectory).mockImplementation( + async (dir) => { + if (dir === '/project/.gemini/agents') { + return { + agents: [MOCK_AGENT_WITH_HASH], + errors: [], + }; + } + return { agents: [], errors: [] }; + }, + ); + + const emitSpy = vi.spyOn(coreEvents, 'emitAgentsDiscovered'); + + await registry.initialize(); + + expect(registry.getDefinition('ProjectAgent')).toBeDefined(); + expect(emitSpy).not.toHaveBeenCalled(); + }); + + it('should register agents without hash (legacy/safe?)', async () => { + // Current logic: if no hash, allow it. + const agentNoHash = { ...MOCK_AGENT_WITH_HASH, metadata: undefined }; + vi.mocked(tomlLoader.loadAgentsFromDirectory).mockImplementation( + async (dir) => { + if (dir === '/project/.gemini/agents') { + return { + agents: [agentNoHash], + errors: [], + }; + } + return { agents: [], errors: [] }; + }, + ); + + await registry.initialize(); + + expect(registry.getDefinition('ProjectAgent')).toBeDefined(); + }); + + it('acknowledgeAgent should acknowledge and register agent', async () => { + await registry.acknowledgeAgent(MOCK_AGENT_WITH_HASH); + + // Verify against real service state + expect( + await ackService.isAcknowledged('/project', 'ProjectAgent', 'hash123'), + ).toBe(true); + + expect(registry.getDefinition('ProjectAgent')).toBeDefined(); + }); +}); diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts index f58b6fa0ae..581e9f2b52 100644 --- a/packages/core/src/agents/types.ts +++ b/packages/core/src/agents/types.ts @@ -74,6 +74,10 @@ export interface BaseAgentDefinition< experimental?: boolean; inputConfig: InputConfig; outputConfig?: OutputConfig; + metadata?: { + hash?: string; + filePath?: string; + }; } export interface LocalAgentDefinition< diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index c0b96a292f..2dd235becf 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -100,6 +100,7 @@ import type { FetchAdminControlsResponse } from '../code_assist/types.js'; import { getCodeAssistServer } from '../code_assist/codeAssist.js'; import type { Experiments } from '../code_assist/experiments/experiments.js'; import { AgentRegistry } from '../agents/registry.js'; +import { AcknowledgedAgentsService } from '../agents/acknowledgedAgents.js'; import { setGlobalProxy } from '../utils/fetch.js'; import { SubagentTool } from '../agents/subagent-tool.js'; import { getExperiments } from '../code_assist/experiments/experiments.js'; @@ -416,6 +417,7 @@ export class Config { private promptRegistry!: PromptRegistry; private resourceRegistry!: ResourceRegistry; private agentRegistry!: AgentRegistry; + private readonly acknowledgedAgentsService: AcknowledgedAgentsService; private skillManager!: SkillManager; private sessionId: string; private clientVersion: string; @@ -705,6 +707,7 @@ export class Config { params.approvalMode ?? params.policyEngineConfig?.approvalMode, }); this.messageBus = new MessageBus(this.policyEngine, this.debugMode); + this.acknowledgedAgentsService = new AcknowledgedAgentsService(); this.skillManager = new SkillManager(); this.outputSettings = { format: params.output?.format ?? OutputFormat.TEXT, @@ -1138,6 +1141,10 @@ export class Config { return this.agentRegistry; } + getAcknowledgedAgentsService(): AcknowledgedAgentsService { + return this.acknowledgedAgentsService; + } + getToolRegistry(): ToolRegistry { return this.toolRegistry; } diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index da7142d09c..ac7efb8103 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -66,6 +66,14 @@ export class Storage { return path.join(Storage.getGlobalGeminiDir(), 'agents'); } + static getAcknowledgedAgentsPath(): string { + return path.join( + Storage.getGlobalGeminiDir(), + 'acknowledgments', + 'agents.json', + ); + } + static getSystemSettingsPath(): string { if (process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']) { return process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']; diff --git a/packages/core/src/utils/events.ts b/packages/core/src/utils/events.ts index 8fd6a73751..d5f8f715aa 100644 --- a/packages/core/src/utils/events.ts +++ b/packages/core/src/utils/events.ts @@ -5,6 +5,7 @@ */ import { EventEmitter } from 'node:events'; +import type { AgentDefinition } from '../agents/types.js'; import type { McpClient } from '../tools/mcp-client.js'; import type { ExtensionEvents } from './extensionLoader.js'; @@ -110,6 +111,13 @@ export interface RetryAttemptPayload { model: string; } +/** + * Payload for the 'agents-discovered' event. + */ +export interface AgentsDiscoveredPayload { + agents: AgentDefinition[]; +} + export enum CoreEvent { UserFeedback = 'user-feedback', ModelChanged = 'model-changed', @@ -125,6 +133,7 @@ export enum CoreEvent { AgentsRefreshed = 'agents-refreshed', AdminSettingsChanged = 'admin-settings-changed', RetryAttempt = 'retry-attempt', + AgentsDiscovered = 'agents-discovered', } export interface CoreEvents extends ExtensionEvents { @@ -142,6 +151,7 @@ export interface CoreEvents extends ExtensionEvents { [CoreEvent.AgentsRefreshed]: never[]; [CoreEvent.AdminSettingsChanged]: never[]; [CoreEvent.RetryAttempt]: [RetryAttemptPayload]; + [CoreEvent.AgentsDiscovered]: [AgentsDiscoveredPayload]; } type EventBacklogItem = { @@ -264,6 +274,14 @@ export class CoreEventEmitter extends EventEmitter { this.emit(CoreEvent.RetryAttempt, payload); } + /** + * Notifies subscribers that new unacknowledged agents have been discovered. + */ + emitAgentsDiscovered(agents: AgentDefinition[]): void { + const payload: AgentsDiscoveredPayload = { agents }; + this._emitOrQueue(CoreEvent.AgentsDiscovered, payload); + } + /** * Flushes buffered messages. Call this immediately after primary UI listener * subscribes.