mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-02-01 22:48:03 +00:00
feat(agents): implement first-run experience for project-level sub-agents (#17266)
This commit is contained in:
committed by
GitHub
parent
d745d86af1
commit
2271bbb339
@@ -198,6 +198,7 @@ const mockUIActions: UIActions = {
|
||||
setEmbeddedShellFocused: vi.fn(),
|
||||
setAuthContext: vi.fn(),
|
||||
handleRestart: vi.fn(),
|
||||
handleNewAgentsSelect: vi.fn(),
|
||||
};
|
||||
|
||||
export const renderWithProviders = (
|
||||
|
||||
@@ -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<AgentDefinition[] | null>(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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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 <IdeTrustChangeDialog reason={uiState.ideTrustRestartReason} />;
|
||||
}
|
||||
if (uiState.newAgents) {
|
||||
return (
|
||||
<NewAgentsNotification
|
||||
agents={uiState.newAgents}
|
||||
onSelect={uiActions.handleNewAgentsSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (uiState.proQuotaRequest) {
|
||||
return (
|
||||
<ProQuotaDialog
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { renderWithProviders as render } from '../../test-utils/render.js';
|
||||
import { NewAgentsNotification } from './NewAgentsNotification.js';
|
||||
|
||||
describe('NewAgentsNotification', () => {
|
||||
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(
|
||||
<NewAgentsNotification agents={mockAgents} onSelect={onSelect} />,
|
||||
);
|
||||
|
||||
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(
|
||||
<NewAgentsNotification agents={manyAgents} onSelect={onSelect} />,
|
||||
);
|
||||
|
||||
const frame = lastFrame();
|
||||
expect(frame).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
96
packages/cli/src/ui/components/NewAgentsNotification.tsx
Normal file
96
packages/cli/src/ui/components/NewAgentsNotification.tsx
Normal file
@@ -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<RadioSelectItem<NewAgentsChoice>> = [
|
||||
{
|
||||
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 (
|
||||
<Box flexDirection="column" width="100%">
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor={theme.status.warning}
|
||||
padding={1}
|
||||
marginLeft={1}
|
||||
marginRight={1}
|
||||
>
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
New Agents Discovered
|
||||
</Text>
|
||||
<Text color={theme.text.primary}>
|
||||
The following agents were found in this project. Please review them:
|
||||
</Text>
|
||||
<Box
|
||||
flexDirection="column"
|
||||
marginTop={1}
|
||||
borderStyle="single"
|
||||
padding={1}
|
||||
>
|
||||
{displayAgents.map((agent) => (
|
||||
<Box key={agent.name}>
|
||||
<Box flexShrink={0}>
|
||||
<Text bold color={theme.text.primary}>
|
||||
- {agent.name}:{' '}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.secondary}> {agent.description}</Text>
|
||||
</Box>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<Text color={theme.text.secondary}>
|
||||
... and {remaining} more.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<RadioButtonSelect
|
||||
items={options}
|
||||
onSelect={onSelect}
|
||||
isFocused={true}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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) │
|
||||
│ │
|
||||
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
export const UIActionsContext = createContext<UIActions | null>(null);
|
||||
|
||||
@@ -155,6 +155,7 @@ export interface UIState {
|
||||
terminalBackgroundColor: TerminalBackgroundColor;
|
||||
settingsNonce: number;
|
||||
adminSettingsChanged: boolean;
|
||||
newAgents: AgentDefinition[] | null;
|
||||
}
|
||||
|
||||
export const UIStateContext = createContext<UIState | null>(null);
|
||||
|
||||
97
packages/core/src/agents/acknowledgedAgents.test.ts
Normal file
97
packages/core/src/agents/acknowledgedAgents.test.ts
Normal file
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
85
packages/core/src/agents/acknowledgedAgents.ts
Normal file
85
packages/core/src/agents/acknowledgedAgents.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
await this.load();
|
||||
if (!this.acknowledgedAgents[projectPath]) {
|
||||
this.acknowledgedAgents[projectPath] = {};
|
||||
}
|
||||
this.acknowledgedAgents[projectPath][agentName] = hash;
|
||||
await this.save();
|
||||
}
|
||||
}
|
||||
@@ -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<FrontmatterAgentDefinition[]> {
|
||||
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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<void> {
|
||||
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(
|
||||
|
||||
169
packages/core/src/agents/registry_acknowledgement.test.ts
Normal file
169
packages/core/src/agents/registry_acknowledgement.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,10 @@ export interface BaseAgentDefinition<
|
||||
experimental?: boolean;
|
||||
inputConfig: InputConfig;
|
||||
outputConfig?: OutputConfig<TOutput>;
|
||||
metadata?: {
|
||||
hash?: string;
|
||||
filePath?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LocalAgentDefinition<
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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<CoreEvents> {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user