Compare commits

..

1 Commits

Author SHA1 Message Date
Sehoon Shon 8c5f2708cc perf(cli): optimize startup by skipping redundant parent authentication 2026-03-06 01:34:50 -05:00
34 changed files with 170 additions and 383 deletions
+3 -4
View File
@@ -17,10 +17,9 @@ prefix.
**Example:** `!ls -la`
This executes `ls -la` immediately and prints the output to your terminal.
Gemini CLI also records the command and its output in the current session
context, so the model can reference it in follow-up prompts. Very large outputs
may be truncated.
This executes `ls -la` immediately and prints the output to your terminal. The
AI doesn't "see" this output unless you paste it back into the chat or use it in
a prompt.
### Scenario: Entering Shell mode
+1 -1
View File
@@ -72,7 +72,7 @@ session's token usage, as well as your overall quota and usage for the supported
models.
For more information on the `/stats` command and its subcommands, see the
[Command Reference](../reference/commands.md#stats).
[Command Reference](../../reference/commands.md#stats).
## Next steps
+1 -1
View File
@@ -151,7 +151,7 @@ session's token usage, as well as information about the limits associated with
your current quota.
For more information on the `/stats` command and its subcommands, see the
[Command Reference](../reference/commands.md#stats).
[Command Reference](../../reference/commands.md#stats).
A summary of model usage is also presented on exit at the end of a session.
+4
View File
@@ -25,6 +25,10 @@ export function setDeferredCommand(command: DeferredCommand) {
deferredCommand = command;
}
export function getDeferredCommand(): DeferredCommand | undefined {
return deferredCommand;
}
export async function runDeferredCommand(settings: MergedSettings) {
if (!deferredCommand) {
return;
+41 -2
View File
@@ -43,6 +43,7 @@ import {
debugLogger,
coreEvents,
AuthType,
fetchCachedCredentials,
} from '@google/gemini-cli-core';
import { act } from 'react';
import { type InitializationResult } from './core/initializer.js';
@@ -130,6 +131,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
off: vi.fn(),
drainBacklogs: vi.fn(),
},
fetchCachedCredentials: vi.fn().mockResolvedValue({}),
};
});
@@ -911,12 +913,47 @@ describe('gemini.tsx main function exit codes', () => {
}
});
it('should exit with 41 for auth failure during sandbox setup', async () => {
it('should skip refreshAuth in parent process when relaunch is expected', async () => {
vi.stubEnv('SANDBOX', '');
const refreshAuthSpy = vi.fn();
vi.mocked(loadCliConfig).mockResolvedValue(
createMockConfig({
refreshAuth: refreshAuthSpy,
getRemoteAdminSettings: vi.fn().mockReturnValue(undefined),
isInteractive: vi.fn().mockReturnValue(true),
}),
);
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
merged: {
security: { auth: { selectedType: 'google', useExternal: false } },
advanced: { autoConfigureMemory: false },
},
}),
);
vi.mocked(parseArguments).mockResolvedValue({} as CliArgs);
// Initial process (no SANDBOX, no NO_RELAUNCH)
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
try {
await main();
} catch (e) {
if (!(e instanceof MockProcessExitError)) throw e;
}
expect(refreshAuthSpy).not.toHaveBeenCalled();
});
it('should exit with 41 for auth failure during sandbox setup when auth IS required', async () => {
vi.stubEnv('SANDBOX', '');
// Force mustAuthNow = true by simulating sandbox entry without credentials
vi.mocked(loadSandboxConfig).mockResolvedValue({
command: 'docker',
image: 'test-image',
});
vi.mocked(fetchCachedCredentials).mockResolvedValue(null);
vi.mocked(loadCliConfig).mockResolvedValue(
createMockConfig({
refreshAuth: vi.fn().mockRejectedValue(new Error('Auth failed')),
@@ -931,7 +968,9 @@ describe('gemini.tsx main function exit codes', () => {
},
}),
);
vi.mocked(parseArguments).mockResolvedValue({} as CliArgs);
vi.mocked(parseArguments).mockResolvedValue({
sandbox: true,
} as unknown as CliArgs);
try {
await main();
+43 -48
View File
@@ -72,7 +72,6 @@ import {
getVersion,
ValidationCancelledError,
ValidationRequiredError,
type AdminControlsSettings,
} from '@google/gemini-cli-core';
import {
initializeApp,
@@ -108,8 +107,9 @@ import { OverflowProvider } from './ui/contexts/OverflowContext.js';
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
import { profiler } from './ui/components/DebugProfiler.js';
import { runDeferredCommand } from './deferred.js';
import { runDeferredCommand, getDeferredCommand } from './deferred.js';
import { SlashCommandConflictHandler } from './services/SlashCommandConflictHandler.js';
import { fetchCachedCredentials } from '@google/gemini-cli-core';
const SLOW_RENDER_MS = 200;
@@ -328,13 +328,6 @@ export async function startInteractiveUI(
export async function main() {
const cliStartupHandle = startupProfiler.start('cli_startup');
// Listen for admin controls from parent process (IPC) in non-sandbox mode. In
// sandbox mode, we re-fetch the admin controls from the server once we enter
// the sandbox.
// TODO: Cache settings in sandbox mode as well.
const adminControlsListner = setupAdminControlsListener();
registerCleanup(adminControlsListner.cleanup);
const cleanupStdio = patchStdio();
registerSyncCleanup(() => {
// This is needed to ensure we don't lose any buffered output.
@@ -446,13 +439,50 @@ export async function main() {
const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, {
projectHooks: settings.workspace.settings.hooks,
});
adminControlsListner.setConfig(partialConfig);
const isEnteringSandbox = !process.env['SANDBOX'] && !!argv.sandbox;
// We relaunch if we are not already in a sandbox and not already in a
// relaunched process. relaunchAppInChildProcess ensures we always have a
// child process that can be internally restarted.
const willRelaunch =
!process.env['SANDBOX'] && !process.env['GEMINI_CLI_NO_RELAUNCH'];
// Determine if we MUST authenticate in the parent process
const hasDeferredCommand = !!getDeferredCommand();
let needsInteractiveSandboxLogin = false;
if (isEnteringSandbox && !settings.merged.security.auth.useExternal) {
const authType =
settings.merged.security.auth.selectedType ||
(process.env['CLOUD_SHELL'] === 'true' ||
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true'
? AuthType.COMPUTE_ADC
: AuthType.LOGIN_WITH_GOOGLE);
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
const existingCredentials = await fetchCachedCredentials();
if (!existingCredentials) {
// Sandbox environment might block interactive login, so do it here
needsInteractiveSandboxLogin = true;
}
}
}
const mustAuthNow = hasDeferredCommand || needsInteractiveSandboxLogin;
// Refresh auth to fetch remote admin settings from CCPA and before entering
// the sandbox because the sandbox will interfere with the Oauth2 web
// redirect.
//
// We skip this in the parent process if we are about to relaunch, as the
// child process will perform its own authentication and fetch admin settings
// itself.
let initialAuthFailed = false;
if (!settings.merged.security.auth.useExternal) {
if (
!settings.merged.security.auth.useExternal &&
(!willRelaunch || mustAuthNow)
) {
try {
if (
partialConfig.isInteractive() &&
@@ -501,6 +531,7 @@ export async function main() {
}
// Run deferred command now that we have admin settings.
// Note: if auth was skipped, settings.merged.admin will use defaults.
await runDeferredCommand(settings.merged);
// hop into sandbox if we are outside and sandboxing is enabled
@@ -558,7 +589,7 @@ export async function main() {
} else {
// Relaunch app so we always have a child process that can be internally
// restarted if needed.
await relaunchAppInChildProcess(memoryArgs, [], remoteAdminSettings);
await relaunchAppInChildProcess(memoryArgs, []);
}
}
@@ -577,8 +608,6 @@ export async function main() {
// access to the project identifier.
await config.storage.initialize();
adminControlsListner.setConfig(config);
if (config.isInteractive() && settings.merged.general.devtools) {
const { setupInitialActivityLogger } = await import(
'./utils/devtoolsService.js'
@@ -870,37 +899,3 @@ export function initializeOutputListenersAndFlush() {
}
coreEvents.drainBacklogs();
}
function setupAdminControlsListener() {
let pendingSettings: AdminControlsSettings | undefined;
let config: Config | undefined;
const messageHandler = (msg: unknown) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const message = msg as {
type?: string;
settings?: AdminControlsSettings;
};
if (message?.type === 'admin-settings' && message.settings) {
if (config) {
config.setRemoteAdminSettings(message.settings);
} else {
pendingSettings = message.settings;
}
}
};
process.on('message', messageHandler);
return {
setConfig: (newConfig: Config) => {
config = newConfig;
if (pendingSettings) {
config.setRemoteAdminSettings(pendingSettings);
}
},
cleanup: () => {
process.off('message', messageHandler);
},
};
}
-5
View File
@@ -131,11 +131,6 @@ vi.mock('../ui/components/GeminiRespondingSpinner.js', async () => {
};
});
vi.mock('../ui/utils/terminalSetup.js', () => ({
shouldPromptForTerminalSetup: vi.fn().mockResolvedValue(false),
useTerminalSetupPrompt: vi.fn(),
}));
export interface AppRigOptions {
fakeResponsesPath?: string;
terminalWidth?: number;
@@ -203,10 +203,6 @@ vi.mock('../utils/events.js');
vi.mock('../utils/handleAutoUpdate.js');
vi.mock('./utils/ConsolePatcher.js');
vi.mock('../utils/cleanup.js');
vi.mock('./utils/terminalSetup.js', () => ({
shouldPromptForTerminalSetup: vi.fn().mockResolvedValue(false),
useTerminalSetupPrompt: vi.fn(),
}));
import { useHistory } from './hooks/useHistoryManager.js';
import { useThemeCommand } from './hooks/useThemeCommand.js';
-9
View File
@@ -2484,15 +2484,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
onHintClear: () => {},
onHintSubmit: () => {},
handleRestart: async () => {
if (process.send) {
const remoteSettings = config.getRemoteAdminSettings();
if (remoteSettings) {
process.send({
type: 'admin-settings-update',
settings: remoteSettings,
});
}
}
await relaunchApp();
},
handleNewAgentsSelect: async (choice: NewAgentsChoice) => {
@@ -4,7 +4,6 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { type Config } from '@google/gemini-cli-core';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
@@ -12,12 +11,10 @@ import { relaunchApp } from '../../utils/processUtils.js';
interface LoginWithGoogleRestartDialogProps {
onDismiss: () => void;
config: Config;
}
export const LoginWithGoogleRestartDialog = ({
onDismiss,
config,
}: LoginWithGoogleRestartDialogProps) => {
useKeypress(
(key) => {
@@ -26,15 +23,6 @@ export const LoginWithGoogleRestartDialog = ({
return true;
} else if (key.name === 'r' || key.name === 'R') {
setTimeout(async () => {
if (process.send) {
const remoteSettings = config.getRemoteAdminSettings();
if (remoteSettings) {
process.send({
type: 'admin-settings-update',
settings: remoteSettings,
});
}
}
await relaunchApp();
}, 100);
return true;
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { Box, Text } from 'ink';
import type React from 'react';
import { useMemo } from 'react';
import { theme } from '../semantic-colors.js';
@@ -58,7 +58,6 @@ export const RewindConfirmation: React.FC<RewindConfirmationProps> = ({
terminalWidth,
timestamp,
}) => {
const isScreenReaderEnabled = useIsScreenReaderEnabled();
useKeypress(
(key) => {
if (keyMatchers[Command.ESCAPE](key)) {
@@ -84,53 +83,6 @@ export const RewindConfirmation: React.FC<RewindConfirmationProps> = ({
option.value !== RewindOutcome.RevertOnly,
);
}, [stats]);
if (isScreenReaderEnabled) {
return (
<Box flexDirection="column" width={terminalWidth}>
<Text bold>Confirm Rewind</Text>
{stats && (
<Box flexDirection="column">
<Text>
{stats.fileCount === 1
? `File: ${stats.details?.at(0)?.fileName}`
: `${stats.fileCount} files affected`}
</Text>
<Text>Lines added: {stats.addedLines}</Text>
<Text>Lines removed: {stats.removedLines}</Text>
{timestamp && <Text>({formatTimeAgo(timestamp)})</Text>}
<Text>
Note: Rewinding does not affect files edited manually or by the
shell tool.
</Text>
</Box>
)}
{!stats && (
<Box>
<Text color={theme.text.secondary}>No code changes to revert.</Text>
{timestamp && (
<Text color={theme.text.secondary}>
{' '}
({formatTimeAgo(timestamp)})
</Text>
)}
</Box>
)}
<Text>Select an action:</Text>
<Text color={theme.text.secondary}>
Use arrow keys to navigate, Enter to confirm, Esc to cancel.
</Text>
<RadioButtonSelect
items={options}
onSelect={handleSelect}
isFocused={true}
/>
</Box>
);
}
return (
<Box
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { act } from 'react';
import { renderWithProviders } from '../../test-utils/render.js';
import { RewindViewer } from './RewindViewer.js';
@@ -14,11 +14,6 @@ import type {
MessageRecord,
} from '@google/gemini-cli-core';
vi.mock('ink', async () => {
const actual = await vi.importActual<typeof import('ink')>('ink');
return { ...actual, useIsScreenReaderEnabled: vi.fn(() => false) };
});
vi.mock('./CliSpinner.js', () => ({
CliSpinner: () => 'MockSpinner',
}));
@@ -76,35 +71,6 @@ describe('RewindViewer', () => {
vi.restoreAllMocks();
});
describe('Screen Reader Accessibility', () => {
beforeEach(async () => {
const { useIsScreenReaderEnabled } = await import('ink');
vi.mocked(useIsScreenReaderEnabled).mockReturnValue(true);
});
afterEach(async () => {
const { useIsScreenReaderEnabled } = await import('ink');
vi.mocked(useIsScreenReaderEnabled).mockReturnValue(false);
});
it('renders the rewind viewer with conversation items', async () => {
const conversation = createConversation([
{ type: 'user', content: 'Hello', id: '1', timestamp: '1' },
]);
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<RewindViewer
conversation={conversation}
onExit={vi.fn()}
onRewind={vi.fn()}
/>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Rewind');
expect(lastFrame()).toContain('Hello');
unmount();
});
});
describe('Rendering', () => {
it.each([
{ name: 'nothing interesting for empty conversation', messages: [] },
@@ -434,31 +400,3 @@ describe('RewindViewer', () => {
unmount2();
});
});
it('renders accessible screen reader view when screen reader is enabled', async () => {
const { useIsScreenReaderEnabled } = await import('ink');
vi.mocked(useIsScreenReaderEnabled).mockReturnValue(true);
const messages: MessageRecord[] = [
{ type: 'user', content: 'Hello world', id: '1', timestamp: '1' },
{ type: 'user', content: 'Second message', id: '2', timestamp: '2' },
];
const conversation = createConversation(messages);
const onExit = vi.fn();
const onRewind = vi.fn();
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<RewindViewer
conversation={conversation}
onExit={onExit}
onRewind={onRewind}
/>,
);
await waitUntilReady();
const frame = lastFrame();
expect(frame).toContain('Rewind - Select a conversation point:');
expect(frame).toContain('Stay at current position');
vi.mocked(useIsScreenReaderEnabled).mockReturnValue(false);
unmount();
});
@@ -6,7 +6,7 @@
import type React from 'react';
import { useMemo, useState } from 'react';
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { Box, Text } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
import {
type ConversationRecord,
@@ -50,7 +50,6 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
}) => {
const [isRewinding, setIsRewinding] = useState(false);
const { terminalWidth, terminalHeight } = useUIState();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
const {
selectedMessageId,
getStats,
@@ -129,6 +128,7 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
5,
terminalHeight - DIALOG_PADDING - HEADER_HEIGHT - CONTROLS_HEIGHT - 2,
);
const maxItemsToShow = Math.max(1, Math.floor(listHeight / 4));
if (selectedMessageId) {
@@ -182,41 +182,6 @@ export const RewindViewer: React.FC<RewindViewerProps> = ({
);
}
if (isScreenReaderEnabled) {
return (
<Box flexDirection="column" width={terminalWidth}>
<Text bold>Rewind - Select a conversation point:</Text>
<BaseSelectionList
items={items}
initialIndex={items.length - 1}
isFocused={true}
showNumbers={true}
wrapAround={false}
onSelect={(item: MessageRecord) => {
if (item?.id) {
if (item.id === 'current-position') {
onExit();
} else {
selectMessage(item.id);
}
}
}}
renderItem={(itemWrapper) => {
const item = itemWrapper.value;
const text =
item.id === 'current-position'
? 'Stay at current position'
: getCleanedRewindText(item);
return <Text>{text}</Text>;
}}
/>
<Text color={theme.text.secondary}>
Press Esc to exit, Enter to select, arrow keys to navigate.
</Text>
</Box>
);
}
return (
<Box
borderStyle="round"
+2 -18
View File
@@ -6,10 +6,7 @@
import { spawn } from 'node:child_process';
import { RELAUNCH_EXIT_CODE } from './processUtils.js';
import {
writeToStderr,
type AdminControlsSettings,
} from '@google/gemini-cli-core';
import { writeToStderr } from '@google/gemini-cli-core';
export async function relaunchOnExitCode(runner: () => Promise<number>) {
while (true) {
@@ -34,14 +31,11 @@ export async function relaunchOnExitCode(runner: () => Promise<number>) {
export async function relaunchAppInChildProcess(
additionalNodeArgs: string[],
additionalScriptArgs: string[],
remoteAdminSettings?: AdminControlsSettings,
) {
if (process.env['GEMINI_CLI_NO_RELAUNCH']) {
return;
}
let latestAdminSettings = remoteAdminSettings;
const runner = () => {
// process.argv is [node, script, ...args]
// We want to construct [ ...nodeArgs, script, ...scriptArgs]
@@ -61,20 +55,10 @@ export async function relaunchAppInChildProcess(
process.stdin.pause();
const child = spawn(process.execPath, nodeArgs, {
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
stdio: ['inherit', 'inherit', 'inherit'],
env: newEnv,
});
if (latestAdminSettings) {
child.send({ type: 'admin-settings', settings: latestAdminSettings });
}
child.on('message', (msg: { type?: string; settings?: unknown }) => {
if (msg.type === 'admin-settings-update' && msg.settings) {
latestAdminSettings = msg.settings as AdminControlsSettings;
}
});
return new Promise<number>((resolve, reject) => {
child.on('error', reject);
child.on('close', (code) => {
@@ -31,7 +31,6 @@ describe('agent-scheduler', () => {
mockConfig = {
getMessageBus: vi.fn().mockReturnValue(mockMessageBus),
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
getAgentsSettings: vi.fn().mockReturnValue({}),
} as unknown as Mocked<Config>;
});
@@ -48,7 +47,6 @@ describe('agent-scheduler', () => {
const options = {
schedulerId: 'subagent-1',
agentName: 'mock-agent',
parentCallId: 'parent-1',
toolRegistry: mockToolRegistry as unknown as ToolRegistry,
signal: new AbortController().signal,
@@ -19,8 +19,6 @@ import type { EditorType } from '../utils/editor.js';
export interface AgentSchedulingOptions {
/** The unique ID for this agent's scheduler. */
schedulerId: string;
/** The name of the agent executing the tools. */
agentName: string;
/** The ID of the tool call that invoked this agent. */
parentCallId?: string;
/** The tool registry specific to this agent. */
@@ -48,7 +46,6 @@ export async function scheduleAgentTools(
): Promise<CompletedToolCall[]> {
const {
schedulerId,
agentName,
parentCallId,
toolRegistry,
signal,
@@ -61,16 +58,6 @@ export async function scheduleAgentTools(
const agentConfig: Config = Object.create(config);
agentConfig.getToolRegistry = () => toolRegistry;
// Apply any subagent-specific approval mode override, fallback to the main config mode.
const overrideMode =
config.getAgentsSettings()?.overrides?.[agentName]?.approvalMode;
if (overrideMode) {
agentConfig.getApprovalMode = () => overrideMode;
} else {
// Subagents follow the global approval mode by default.
agentConfig.getApprovalMode = () => config.getApprovalMode();
}
const scheduler = new Scheduler({
config: agentConfig,
messageBus: config.getMessageBus(),
@@ -1073,7 +1073,6 @@ export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
toolRequests,
{
schedulerId: this.agentId,
agentName: this.definition.name,
parentCallId: this.parentCallId,
toolRegistry: this.toolRegistry,
signal,
+35
View File
@@ -23,6 +23,7 @@ import {
type ModelConfig,
ModelConfigService,
} from '../services/modelConfigService.js';
import { PolicyDecision, PRIORITY_SUBAGENT_TOOL } from '../policy/types.js';
/**
* Returns the model config alias for a given agent definition.
@@ -314,6 +315,39 @@ export class AgentRegistry {
this.agents.set(mergedDefinition.name, mergedDefinition);
this.registerModelConfigs(mergedDefinition);
this.addAgentPolicy(mergedDefinition);
}
private addAgentPolicy(definition: AgentDefinition<z.ZodTypeAny>): 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;
}
// Clean up any old dynamic policy for this tool (e.g. if we are overwriting an agent)
policyEngine.removeRulesForTool(definition.name, 'AgentRegistry (Dynamic)');
// Add the new dynamic policy
policyEngine.addRule({
toolName: definition.name,
decision:
definition.kind === 'local'
? PolicyDecision.ALLOW
: PolicyDecision.ASK_USER,
priority: PRIORITY_SUBAGENT_TOOL,
source: 'AgentRegistry (Dynamic)',
});
}
private isAgentEnabled<TOutput extends z.ZodTypeAny>(
@@ -427,6 +461,7 @@ export class AgentRegistry {
);
}
this.agents.set(definition.name, definition);
this.addAgentPolicy(definition);
} catch (e) {
const errorMessage = `Error loading A2A agent "${definition.name}": ${e instanceof Error ? e.message : String(e)}`;
debugLogger.warn(`[AgentRegistry] ${errorMessage}`, e);
@@ -52,7 +52,6 @@ export class SubagentTool extends BaseDeclarativeTool<AgentInputs, ToolResult> {
messageBus,
/* isOutputMarkdown */ true,
/* canUpdateOutput */ true,
/* toolAnnotations */ { isSubagent: true, agentKind: definition.kind },
);
}
+1 -1
View File
@@ -642,7 +642,7 @@ export function getAvailablePort(): Promise<number> {
});
}
async function fetchCachedCredentials(): Promise<
export async function fetchCachedCredentials(): Promise<
Credentials | JWTInput | null
> {
const useEncryptedStorage = getUseEncryptedStorageFlag();
-1
View File
@@ -234,7 +234,6 @@ export interface AgentOverride {
modelConfig?: ModelConfig;
runConfig?: AgentRunConfig;
enabled?: boolean;
approvalMode?: ApprovalMode;
}
export interface AgentSettings {
-17
View File
@@ -728,23 +728,6 @@ describe('Gemini Client (client.ts)', () => {
);
});
it('yields UserCancelled when processTurn throws AbortError', async () => {
const abortError = new Error('Aborted');
abortError.name = 'AbortError';
vi.spyOn(client['loopDetector'], 'turnStarted').mockRejectedValueOnce(
abortError,
);
const stream = client.sendMessageStream(
[{ text: 'Hi' }],
new AbortController().signal,
'prompt-id-abort-error',
);
const events = await fromAsync(stream);
expect(events).toEqual([{ type: GeminiEventType.UserCancelled }]);
});
it.each([
{
compressionStatus:
+1 -7
View File
@@ -34,7 +34,7 @@ import {
type RetryAvailabilityContext,
} from '../utils/retry.js';
import type { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
import { getErrorMessage, isAbortError } from '../utils/errors.js';
import { getErrorMessage } from '../utils/errors.js';
import { tokenLimit } from './tokenLimits.js';
import type {
ChatRecordingService,
@@ -957,12 +957,6 @@ export class GeminiClient {
);
}
}
} catch (error) {
if (signal?.aborted || isAbortError(error)) {
yield { type: GeminiEventType.UserCancelled };
return turn;
}
throw error;
} finally {
const hookState = this.hookStateMap.get(prompt_id);
if (hookState) {
@@ -1,11 +0,0 @@
# Default policy for dynamically registered local subagents
[[rule]]
toolAnnotations = { isSubagent = true, agentKind = "local" }
decision = "allow"
priority = 50
# Default policy for dynamically registered remote subagents
[[rule]]
toolAnnotations = { isSubagent = true, agentKind = "remote" }
decision = "ask_user"
priority = 50
@@ -13,6 +13,7 @@ import {
type SafetyCheckerRule,
InProcessCheckerType,
ApprovalMode,
PRIORITY_SUBAGENT_TOOL,
} from './types.js';
import type { FunctionCall } from '@google/genai';
import { SafetyCheckDecision } from '../safety/protocol.js';
@@ -1594,7 +1595,7 @@ describe('PolicyEngine', () => {
{
toolName: 'unknown_subagent',
decision: PolicyDecision.ALLOW,
priority: 1.05,
priority: PRIORITY_SUBAGENT_TOOL,
},
];
+14 -22
View File
@@ -8,6 +8,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
PolicyDecision,
ApprovalMode,
PRIORITY_SUBAGENT_TOOL,
} from './types.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
@@ -910,27 +911,12 @@ priority = 100
it('should override default subagent rules when in Plan Mode for unknown subagents', async () => {
const planTomlPath = path.resolve(__dirname, 'policies', 'plan.toml');
const planFileContent = await fs.readFile(planTomlPath, 'utf-8');
const subagentsTomlPath = path.resolve(
__dirname,
'policies',
'subagents.toml',
);
const subagentsFileContent = await fs.readFile(subagentsTomlPath, 'utf-8');
const fileContent = await fs.readFile(planTomlPath, 'utf-8');
const tempPolicyDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'plan-policy-test-'),
);
try {
await fs.writeFile(
path.join(tempPolicyDir, 'plan.toml'),
planFileContent,
);
await fs.writeFile(
path.join(tempPolicyDir, 'subagents.toml'),
subagentsFileContent,
);
await fs.writeFile(path.join(tempPolicyDir, 'plan.toml'), fileContent);
const getPolicyTier = () => 1; // Default tier
// 1. Load the actual Plan Mode policies
@@ -946,14 +932,20 @@ priority = 100
});
// 3. Simulate an unknown Subagent being registered (Dynamic Rule)
// Now handled by subagents.toml policy using toolAnnotations
const checkResult = await engine.check(
{ name: 'unknown_subagent' },
{ isSubagent: true, agentKind: 'local' },
);
engine.addRule({
toolName: 'unknown_subagent',
decision: PolicyDecision.ALLOW,
priority: PRIORITY_SUBAGENT_TOOL,
source: 'AgentRegistry (Dynamic)',
});
// 4. Verify Behavior:
// The Plan Mode "Catch-All Deny" (from plan.toml) should override the Subagent Allow
const checkResult = await engine.check(
{ name: 'unknown_subagent' },
undefined,
);
expect(
checkResult.decision,
'Unknown subagent should be DENIED in Plan Mode',
+5
View File
@@ -301,3 +301,8 @@ export interface CheckResult {
rule?: PolicyRule;
}
/**
* Priority for subagent tools (registered dynamically).
* Effective priority matching Tier 1 (Default) read-only tools.
*/
export const PRIORITY_SUBAGENT_TOOL = 1.05;
+3 -4
View File
@@ -606,13 +606,12 @@ function workflowStepResearch(options: PrimaryWorkflowsOptions): string {
}
function workflowStepStrategy(options: PrimaryWorkflowsOptions): string {
if (options.approvedPlan && options.taskTracker) {
return `2. **Strategy:** An approved plan is available for this task. Treat this file as your single source of truth and invoke the task tracker tool to create tasks for this plan. You MUST read this file before proceeding. If you discover new requirements or need to change the approach, confirm with the user and update this plan file to reflect the updated design decisions or discovered requirements. Make sure to update the tracker task list based on this updated plan. Once all implementation and verification steps are finished, provide a **final summary** of the work completed against the plan and offer clear **next steps** to the user (e.g., 'Open a pull request').`;
}
if (options.approvedPlan) {
return `2. **Strategy:** An approved plan is available for this task. Treat this file as your single source of truth. You MUST read this file before proceeding. If you discover new requirements or need to change the approach, confirm with the user and update this plan file to reflect the updated design decisions or discovered requirements. Once all implementation and verification steps are finished, provide a **final summary** of the work completed against the plan and offer clear **next steps** to the user (e.g., 'Open a pull request').`;
}
if (options.approvedPlan && options.taskTracker) {
return `2. **Strategy:** An approved plan is available for this task. Treat this file as your single source of truth and invoke the task tracker tool to create tasks for this plan. You MUST read this file before proceeding. If you discover new requirements or need to change the approach, confirm with the user and update this plan file to reflect the updated design decisions or discovered requirements. Make sure to update the tracker task list based on this updated plan. Once all implementation and verification steps are finished, provide a **final summary** of the work completed against the plan and offer clear **next steps** to the user (e.g., 'Open a pull request').`;
}
if (options.enableWriteTodosTool) {
return `2. **Strategy:** Formulate a grounded plan based on your research.${
+1 -17
View File
@@ -54,23 +54,7 @@ async function resolveExistingRgPath(): Promise<string | null> {
}
let ripgrepAcquisitionPromise: Promise<string | null> | null = null;
/**
* Ensures a ripgrep binary is available.
*
* NOTE:
* - The Gemini CLI currently prefers a managed ripgrep binary downloaded
* into its global bin directory.
* - Even if ripgrep is available on the system PATH, it is intentionally
* not used at this time.
*
* Preference for system-installed ripgrep is blocked on:
* - checksum verification of external binaries
* - internalization of the get-ripgrep dependency
*
* See:
* - feat(core): Prefer rg in system path (#11847)
* - Move get-ripgrep to third_party (#12099)
*/
async function ensureRipgrepAvailable(): Promise<string | null> {
const existingPath = await resolveExistingRgPath();
if (existingPath) {
+6
View File
@@ -228,6 +228,12 @@ export const ALL_BUILTIN_TOOL_NAMES = [
GET_INTERNAL_DOCS_TOOL_NAME,
ENTER_PLAN_MODE_TOOL_NAME,
EXIT_PLAN_MODE_TOOL_NAME,
TRACKER_CREATE_TASK_TOOL_NAME,
TRACKER_UPDATE_TASK_TOOL_NAME,
TRACKER_GET_TASK_TOOL_NAME,
TRACKER_LIST_TASKS_TOOL_NAME,
TRACKER_ADD_DEPENDENCY_TOOL_NAME,
TRACKER_VISUALIZE_TOOL_NAME,
] as const;
/**
+2 -14
View File
@@ -123,19 +123,7 @@ describe('partUtils', () => {
it('should return descriptive string for inlineData part', () => {
const part = { inlineData: { mimeType: 'image/png', data: '' } } as Part;
expect(partToString(part, verboseOptions)).toBe(
'[Image: image/png, 0.0 KB]',
);
});
it('should show size for inlineData with non-empty base64 data', () => {
// 4 base64 chars → ceil(4*3/4) = 3 bytes → 3/1024 ≈ 0.0 KB
const part = {
inlineData: { mimeType: 'audio/mp3', data: 'AAAA' },
} as Part;
expect(partToString(part, verboseOptions)).toBe(
'[Audio: audio/mp3, 0.0 KB]',
);
expect(partToString(part, verboseOptions)).toBe('<image/png>');
});
it('should return an empty string for an unknown part type', () => {
@@ -154,7 +142,7 @@ describe('partUtils', () => {
],
];
expect(partToString(parts as Part, verboseOptions)).toBe(
'start middle[Function Call: func1] end[Audio: audio/mp3, 0.0 KB]',
'start middle[Function Call: func1] end<audio/mp3>',
);
});
});
+1 -12
View File
@@ -63,18 +63,7 @@ export function partToString(
return `[Function Response: ${part.functionResponse.name}]`;
}
if (part.inlineData !== undefined) {
const mimeType = part.inlineData.mimeType ?? 'unknown';
const data = part.inlineData.data ?? '';
const bytes = Math.ceil((data.length * 3) / 4);
const kb = (bytes / 1024).toFixed(1);
const category = mimeType.startsWith('audio/')
? 'Audio'
: mimeType.startsWith('video/')
? 'Video'
: mimeType.startsWith('image/')
? 'Image'
: 'Media';
return `[${category}: ${mimeType}, ${kb} KB]`;
return `<${part.inlineData.mimeType}>`;
}
}
-1
View File
@@ -254,7 +254,6 @@ export class GeminiCliSession {
toolCallsToSchedule,
{
schedulerId: sessionId,
agentName: 'sdk-agent',
toolRegistry: scopedRegistry,
signal: abortSignal,
},
-5
View File
@@ -2241,11 +2241,6 @@
"enabled": {
"type": "boolean",
"description": "Whether to enable the agent."
},
"approvalMode": {
"type": "string",
"description": "Approval mode for this subagent's internal tool calls.",
"enum": ["default", "yolo", "plan", "autoEdit"]
}
}
},