Compare commits

...

6 Commits

Author SHA1 Message Date
Samee Zahid 042bd7fd40 improvements and hardening 2026-04-15 10:48:03 -07:00
Samee Zahid 27f35c3358 feat: add offline/hybrid mode with cloud subagent delegation 2026-04-15 00:15:32 -07:00
Samee Zahid fd481ffc25 docs: add codebase understanding from antigravity 2026-04-15 00:06:27 -07:00
Samee Zahid 1332e110e3 docs: finalize codebase understanding guide with advanced technical details 2026-04-15 00:06:27 -07:00
Samee Zahid 3cff2154c0 docs: expand codebase understanding guide with technical depth 2026-04-15 00:06:27 -07:00
Samee Zahid 8b92be573c docs: add codebase understanding guide 2026-04-15 00:06:27 -07:00
33 changed files with 1422 additions and 148 deletions
+149
View File
@@ -0,0 +1,149 @@
# Codebase understanding
This document provides a deep-dive technical overview of the Gemini CLI
architecture. It is designed for developers who need to understand the
system's inner workings, from startup to advanced autonomous behaviors.
## Repository architecture
Gemini CLI is a monorepo structured to maintain a strict separation between
the user interface and the agent's core reasoning logic.
- **`packages/cli`**: The Terminal User Interface (TUI). Built with React and
Ink, it manages the interactive terminal experience, including keyboard
protocols, rendering, and terminal state management.
- **`packages/core`**: The UI-agnostic engine. It contains the primary
orchestration logic, model routing, tool systems, policy enforcement, and
Gemini API communication.
- **`packages/devtools`**: A suite for real-time inspection of network traffic,
console logs, and session activity.
- **`packages/sdk`**: A library for developers to build third-party tools and
extensions.
- **`packages/vscode-ide-companion`**: A specialized bridge that feeds real-time
editor state (open files, active selections, cursor positions) to the agent.
---
## 1. Application lifecycle
### Startup and initialization
The entry point is `packages/cli/src/gemini.tsx`. The startup sequence is
designed for security and resilience:
1. **I/O redirection**: Standard output streams (`stdout`, `stderr`) are
patched to capture all logs and errors. This allows the CLI to redirect
diagnostic information to the TUI's debug console or a remote DevTools server
without corrupting the user's terminal interface.
2. **Memory-aware relaunch**: The CLI checks the host system's total memory.
If it detects that Node.js's default heap limit is insufficient for complex
codebase analysis, it re-launches itself using the
`--max-old-space-size` flag, targeting approximately 50% of system memory.
3. **Sandboxing**: If configured, the CLI launches a restricted "sandbox"
environment (using Docker, Podman, or a localized process) to isolate the
agent's autonomous actions from the host system.
4. **Interactive (TUI) vs. Non-interactive (CLI)**:
- **Interactive mode**: Initializes the Ink renderer, starting a persistent
React application that manages terminal state via providers.
- **Non-interactive mode**: Executes a streamlined loop in
`nonInteractiveCli.ts`, designed for single prompts or piped input/output
redirection.
---
## 2. Model routing and selection
The `ModelRouterService` (`packages/core/src/routing`) implements a
"Composite Strategy" to select the optimal model for every request.
### Routing strategies
- **classifier**: Uses a lightweight LLM call to categorize the complexity of a
task based on a rubric (Strategic Planning, Multi-step Coordination,
Ambiguity). It chooses between a "Pro" model (for complex reasoning) and a
"Flash" model (for simple operations).
- **approvalMode**: Selects specialized models (like `gemini-2.0-flash-lite`)
when the agent is in specific modes like `Plan Mode`.
- **numericalClassifier**: A deterministic strategy that selects models based
on the number of tokens in the conversation or the length of the history.
- **fallback**: Automatically switches models if the primary model encounters
quota limits (429) or transient API failures.
---
## 3. Intelligent context management
The agent maintains deep project awareness while staying within token limits
through several services in `packages/core/src/services`:
### ChatCompressionService
Triggered when the history exceeds 50% of the model's context window:
1. **State snapshots**: The agent generates a structured `<state_snapshot>`
representing the cumulative knowledge of the session (constraints, progress,
paths).
2. **The "Probe" (Self-Correction)**: A second LLM pass compares the summary
against the original history to ensure no critical technical details or
user-defined constraints were lost, correcting the summary before purging
the history.
### ToolOutputMaskingService
Prevents bulky data (like large shell outputs or file reads) from clogging the
context window. It replaces large `functionResponse` blocks with concise
summaries and persists the full data to temporary files, allowing the agent to
refer to the full data only when necessary.
---
## 4. Advanced tool execution and scheduling
The `Scheduler` (`packages/core/src/scheduler`) is an event-driven state
machine that manages the lifecycle of autonomous actions.
### Lifecycle states
`Validating``AwaitingApproval``Scheduled``Executing``Success`/`Error`
### Key features
- **Policy Engine**: A granular system that evaluates tools based on security
policies (e.g., "Allow read-only tools", "Ask for shell commands"). It can be
configured at the project or user level.
- **Tail calls**: Allows a tool to "link" to another action. For example, a
shell command that produces an error can automatically trigger a "diagnostic"
tool without returning control to the main model.
- **Parallelism**: The scheduler executes independent read-only tools in
parallel while enforcing sequential execution for tools that modify the
environment.
- **MCP integration**: Dynamically loads tools from Model Context Protocol
servers, integrating them seamlessly into the same policy and scheduler
framework.
---
## 5. UI and terminal integration
The `packages/cli/src/ui` directory implements a sophisticated React-based TUI.
### Keyboard and protocols
- **KeypressProvider**: Manages terminal input, supporting complex key
combinations and shortcuts.
- **Kitty keyboard protocol**: Detects terminals that support the Kitty
protocol to enable advanced features like detecting `ctrl+enter` vs `enter`.
- **Vim mode**: A dedicated provider that enables Vim-like navigation (hjkl,
words, search) for both conversation history and input fields.
### Layout and rendering
- **ResizeObserver**: A custom implementation that watches the terminal size
to ensure components (like multi-column layouts or wide tables) adapt
instantly.
- **ConsolePatcher**: Intercepts `console.log`, `console.warn`, and
`console.error`, routing them to the internal debug console (toggled with
`ctrl+d`) or the external DevTools server.
---
## 6. Testing and validation
Gemini CLI uses a tiered testing strategy to ensure reliability:
1. **Unit tests**: Located alongside the source (`*.test.ts`), providing fast
coverage for core logic.
2. **Integration tests**: Located in `integration-tests/`, running the
full CLI against mock and real Gemini API endpoints.
3. **Evals**: Performance benchmarks in `evals/` that measure the agent's
reasoning accuracy and tool-use efficiency over time.
+101
View File
@@ -0,0 +1,101 @@
# Gemini CLI - Codebase Understanding
Gemini CLI is an open-source AI agent designed to let you interact with Google's
Gemini models directly from your terminal. It's built as a **TypeScript
monorepo** (using npm workspaces) and relies heavily on **Node.js**, **React**,
and **Ink** (a library that lets you build terminal UIs using React components).
Here is a high-level walkthrough of the repository to help you understand how
all the pieces fit together.
## 1. High-Level Architecture (The `packages/` Directory)
The project is split into several focused packages to maintain a clean
separation of concerns:
- **`packages/cli`** (The Frontend)
- This is the user-facing terminal UI.
- It uses React + Ink. This means the terminal layout, styling, and
interactions are managed like a modern web app (with hooks, contexts, and
components).
- It handles all the terminal-specific logic like key bindings, processing
mouse/keyboard events, and rendering the chat stream or tool progress
indicators.
- **`packages/core`** (The Brain/Backend)
- This is where the actual "agentic" logic lives. It is entirely UI-agnostic.
- Contains the core looping mechanism that communicates with the Gemini API,
maintains conversation history, compresses context, and evaluates whether
the agent needs to invoke a tool.
- Houses the **Tool Registry** (file system tools, shell runner, web tools)
and the **Policy Engine** (deciding if a tool is safe to run automatically
or needs your permission).
- **`packages/devtools`**
- A Chrome DevTools-like web server that runs locally! If you enable
`general.devtools` in your settings, you can inspect network requests, agent
thoughts, and console logs in a local browser, just like you would for a web
app.
- **`packages/vscode-ide-companion`**
- A VS Code extension that pairs dynamically with the CLI. It allows the
terminal agent to "read" your active editor state, seamlessly pulling
context on exactly what files or lines of code you currently have
highlighted in VS Code.
- **`packages/sdk`**
- Provides libraries and types so people can build custom MCP (Model Context
Protocol) extensions or tools for the CLI.
- **`packages/a2a-server`**
- An experimental Agent-to-Agent server, hinting at future capabilities for
having different agents talk to each other.
## 2. The Core Application Lifecycle
When you type `gemini` in your terminal, here's roughly what happens under the
hood:
1. **Bootstrapping (`packages/cli/src/gemini.tsx`)**: The CLI loads user
configurations, parses command-line arguments, checks authentication, and
verifies if it needs to launch itself in a controlled "sandbox" environment
(using Docker/Podman to isolate dangerous shell tools).
2. **Mode Resolution**: It determines if you are piping data in or running a
single command (`nonInteractiveCli.ts`), or if you are firing up the chat
TUI (Terminal User Interface).
3. **The Agent Loop (`packages/core/src/core/`)**:
- **`GeminiClient`**: The main orchestrator. It manages sessions and
compresses chat histories using `ChatCompressionService` so you don't
breach token limits.
- **`GeminiChat` & `Turn`**: For every prompt you send, a `Turn` is created.
This represents one "exchange" where the model might think, respond, and
realize it needs to search your codebase. It streams these requests back
in real-time.
## 3. The Tool System & Execution
The most powerful aspect of this CLI is its ability to interact with your
environment.
- In `packages/core/src/tools/`, there are native TypeScript implementations for
operations (like reading files, searching directories, or running tests).
- When Gemini asks to use a tool, the **Scheduler**
(`packages/core/src/scheduler/`) intercepts the request.
- It runs the request through the **Policy Engine**
(`packages/core/src/policy/`). Some commands (like `rm -rf`) are flagged and
routed to a **Confirmation Bus**, which pauses execution and asks you in the
UI: _"Do you want to allow this command?"_
- Once approved (or auto-approved), it executes the tool, captures standard
output/error, and pipes that text back to Gemini to continue its thought
process.
## 4. Code Quality, Building, and Testing
- **Bundling & Running**: The project uses `esbuild` to compile everything very
quickly. During development, you can use `npm run start` or `npm run debug`
(which attaches a Node.js inspector).
- **Testing (`vitest`)**: Testing is extremely rigorous here.
- _Unit Tests:_ `npm run test` handles basic component functionality.
- _Integration Tests:_ `npm run test:e2e` simulates an actual sandbox,
mocking/hitting models to make sure the CLI interacts realistically.
- _Evals (`evals/`):_ Standalone performance benchmarks where they evaluate
how smart the CLI is at navigating codebases or using its tools
autonomously.
- **`npm run preflight`**: Before a PR is pushed, this massive script runs
formatting (Prettier), linting (ESLint), type checking (TypeScript), unit
testing, and building, ensuring nothing breaks the main branch.
+40
View File
@@ -3062,6 +3062,46 @@ describe('loadCliConfig gemmaModelRouter', () => {
});
});
describe('loadCliConfig offline mode', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('should enable offline mode by default from schema defaults', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings();
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.isOfflineModeEnabled()).toBe(true);
expect(config.getOfflineSettings().localModelRouting).toBe(
'stub_default_api',
);
});
it('should load explicit offline settings from merged settings', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
general: {
offline: {
enabled: false,
localModelRouting: 'stub_default_api',
},
},
});
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.isOfflineModeEnabled()).toBe(false);
});
});
describe('loadCliConfig fileFiltering', () => {
const originalArgv = process.argv;
+1
View File
@@ -982,6 +982,7 @@ export async function loadCliConfig(
plan: settings.general?.plan?.enabled ?? true,
tracker: settings.experimental?.taskTracker,
directWebFetch: settings.experimental?.directWebFetch,
offline: settings.general?.offline,
planSettings: settings.general?.plan?.directory
? settings.general.plan
: (extensionPlanSettings ?? settings.general?.plan),
@@ -431,6 +431,31 @@ describe('SettingsSchema', () => {
);
});
it('should have offline mode settings in schema', () => {
const offline = getSettingsSchema().general.properties.offline;
expect(offline).toBeDefined();
expect(offline.type).toBe('object');
expect(offline.category).toBe('General');
expect(offline.default).toEqual({});
expect(offline.requiresRestart).toBe(false);
expect(offline.showInDialog).toBe(true);
const enabled = offline.properties.enabled;
expect(enabled).toBeDefined();
expect(enabled.type).toBe('boolean');
expect(enabled.default).toBe(true);
expect(enabled.requiresRestart).toBe(false);
expect(enabled.showInDialog).toBe(true);
const localModelRouting = offline.properties.localModelRouting;
expect(localModelRouting).toBeDefined();
expect(localModelRouting.type).toBe('enum');
expect(localModelRouting.default).toBe('stub_default_api');
expect(localModelRouting.options?.map((o) => o.value)).toEqual([
'stub_default_api',
]);
});
it('should have hooksConfig.notifications setting in schema', () => {
const setting = getSettingsSchema().hooksConfig?.properties.notifications;
expect(setting).toBeDefined();
+38
View File
@@ -325,6 +325,44 @@ const SETTINGS_SCHEMA = {
},
},
},
offline: {
type: 'object',
label: 'Offline Mode',
category: 'General',
requiresRestart: false,
default: {},
description:
'Offline mode settings. Routes work locally by default and delegates complex tasks through a cloud subagent with confirmation.',
showInDialog: true,
properties: {
enabled: {
type: 'boolean',
label: 'Enable Offline Mode',
category: 'General',
requiresRestart: false,
default: true,
description:
'Enable offline mode behavior by default (local-first strategy with explicit cloud delegation).',
showInDialog: true,
},
localModelRouting: {
type: 'enum',
label: 'Offline Local Model Routing',
category: 'General',
requiresRestart: false,
default: 'stub_default_api',
description:
'Selects the offline local-model routing strategy. The current stub still routes through the default API backend.',
showInDialog: false,
options: [
{
value: 'stub_default_api',
label: 'Stub (Default API)',
},
],
},
},
},
retryFetchErrors: {
type: 'boolean',
label: 'Retry Fetch Errors',
@@ -101,6 +101,9 @@ vi.mock('../ui/commands/memoryCommand.js', () => ({ memoryCommand: {} }));
vi.mock('../ui/commands/modelCommand.js', () => ({
modelCommand: { name: 'model' },
}));
vi.mock('../ui/commands/offlineCommand.js', () => ({
offlineCommand: { name: 'offline' },
}));
vi.mock('../ui/commands/privacyCommand.js', () => ({ privacyCommand: {} }));
vi.mock('../ui/commands/quitCommand.js', () => ({ quitCommand: {} }));
vi.mock('../ui/commands/resumeCommand.js', () => ({
@@ -247,6 +250,9 @@ describe('BuiltinCommandLoader', () => {
const mcpCmd = commands.find((c) => c.name === 'mcp');
expect(mcpCmd).toBeDefined();
const offlineCmd = commands.find((c) => c.name === 'offline');
expect(offlineCmd).toBeDefined();
});
it('should include permissions command when folder trust is enabled', async () => {
@@ -43,6 +43,7 @@ import { mcpCommand } from '../ui/commands/mcpCommand.js';
import { memoryCommand } from '../ui/commands/memoryCommand.js';
import { modelCommand } from '../ui/commands/modelCommand.js';
import { oncallCommand } from '../ui/commands/oncallCommand.js';
import { offlineCommand } from '../ui/commands/offlineCommand.js';
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
import { planCommand } from '../ui/commands/planCommand.js';
import { policiesCommand } from '../ui/commands/policiesCommand.js';
@@ -183,6 +184,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
: [mcpCommand]),
memoryCommand,
modelCommand,
offlineCommand,
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
...(this.config?.isPlanEnabled() ? [planCommand] : []),
policiesCommand,
+26
View File
@@ -433,6 +433,10 @@ export const AppContainer = (props: AppContainerProps) => {
);
const [currentModel, setCurrentModel] = useState(config.getModel());
const [isOfflineMode, setIsOfflineMode] = useState(
config.isOfflineModeEnabled(),
);
const [cloudSubagentActive, setCloudSubagentActive] = useState(false);
const [userTier, setUserTier] = useState<UserTierId | undefined>(undefined);
const [quotaStats, setQuotaStats] = useState<QuotaStats | undefined>(() => {
@@ -567,6 +571,14 @@ export const AppContainer = (props: AppContainerProps) => {
const handleModelChanged = () => {
setCurrentModel(config.getModel());
};
const handleOfflineModeChanged = (payload: { enabled: boolean }) => {
setIsOfflineMode(payload.enabled);
};
const handleCloudSubagentExecution = (payload: {
state: 'started' | 'ended';
}) => {
setCloudSubagentActive(payload.state === 'started');
};
const handleQuotaChanged = (payload: {
remaining: number | undefined;
@@ -581,9 +593,19 @@ export const AppContainer = (props: AppContainerProps) => {
};
coreEvents.on(CoreEvent.ModelChanged, handleModelChanged);
coreEvents.on(CoreEvent.OfflineModeChanged, handleOfflineModeChanged);
coreEvents.on(
CoreEvent.CloudSubagentExecution,
handleCloudSubagentExecution,
);
coreEvents.on(CoreEvent.QuotaChanged, handleQuotaChanged);
return () => {
coreEvents.off(CoreEvent.ModelChanged, handleModelChanged);
coreEvents.off(CoreEvent.OfflineModeChanged, handleOfflineModeChanged);
coreEvents.off(
CoreEvent.CloudSubagentExecution,
handleCloudSubagentExecution,
);
coreEvents.off(CoreEvent.QuotaChanged, handleQuotaChanged);
};
}, [config]);
@@ -2493,6 +2515,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
queueErrorMessage,
showApprovalModeIndicator,
allowPlanMode,
isOfflineMode,
cloudSubagentActive,
currentModel,
contextFileNames,
errorCount,
@@ -2604,6 +2628,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
queueErrorMessage,
showApprovalModeIndicator,
allowPlanMode,
isOfflineMode,
cloudSubagentActive,
contextFileNames,
errorCount,
availableTerminalHeight,
@@ -0,0 +1,107 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { offlineCommand } from './offlineCommand.js';
import { SettingScope } from '../../config/settings.js';
import type { CommandContext } from './types.js';
describe('offlineCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
const mockConfig = {
isOfflineModeEnabled: vi.fn().mockReturnValue(true),
getOfflineSettings: vi.fn().mockReturnValue({
enabled: true,
localModelRouting: 'stub_default_api',
}),
setOfflineMode: vi.fn().mockResolvedValue(undefined),
};
mockContext = {
services: {
agentContext: {
config: mockConfig,
},
settings: {
setValue: vi.fn(),
},
},
} as unknown as CommandContext;
});
it('shows offline mode status', async () => {
if (!offlineCommand.action) {
throw new Error('offline command must have an action');
}
const result = await offlineCommand.action(mockContext, '');
expect(result).toEqual(
expect.objectContaining({
type: 'message',
messageType: 'info',
}),
);
expect(result).toEqual(
expect.objectContaining({
content: expect.stringContaining('Offline mode is enabled'),
}),
);
});
it('enables offline mode with /offline on', async () => {
const onCommand = offlineCommand.subCommands?.find((c) => c.name === 'on');
if (!onCommand?.action) {
throw new Error('/offline on command must have an action');
}
const result = await onCommand.action(mockContext, '');
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'general.offline.enabled',
true,
);
expect(
mockContext.services.agentContext?.config.setOfflineMode,
).toHaveBeenCalledWith(true);
expect(result).toEqual(
expect.objectContaining({
type: 'message',
messageType: 'info',
content: 'Offline mode enabled.',
}),
);
});
it('disables offline mode with /offline off', async () => {
const offCommand = offlineCommand.subCommands?.find(
(c) => c.name === 'off',
);
if (!offCommand?.action) {
throw new Error('/offline off command must have an action');
}
const result = await offCommand.action(mockContext, '');
expect(mockContext.services.settings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'general.offline.enabled',
false,
);
expect(
mockContext.services.agentContext?.config.setOfflineMode,
).toHaveBeenCalledWith(false);
expect(result).toEqual(
expect.objectContaining({
type: 'message',
messageType: 'info',
content: 'Offline mode disabled.',
}),
);
});
});
@@ -0,0 +1,99 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { SettingScope } from '../../config/settings.js';
import {
CommandKind,
type CommandContext,
type SlashCommand,
} from './types.js';
function getStatusMessage(context: CommandContext): string {
const config = context.services.agentContext?.config;
if (!config) {
return 'Offline mode status is unavailable because config is not loaded.';
}
const status = config.isOfflineModeEnabled() ? 'enabled' : 'disabled';
const offlineSettings = config.getOfflineSettings();
return `Offline mode is ${status}. Local routing: ${offlineSettings.localModelRouting}. Cloud delegation subagent: cloud-subagent (tool: cloud_subagent).`;
}
async function setOfflineMode(
context: CommandContext,
enabled: boolean,
): Promise<string> {
const config = context.services.agentContext?.config;
if (!config) {
return 'Offline mode could not be changed because config is not loaded.';
}
context.services.settings.setValue(
SettingScope.User,
'general.offline.enabled',
enabled,
);
await config.setOfflineMode(enabled);
const status = enabled ? 'enabled' : 'disabled';
return `Offline mode ${status}.`;
}
const statusCommand: SlashCommand = {
name: 'status',
description: 'Show current offline mode status',
kind: CommandKind.BUILT_IN,
autoExecute: true,
isSafeConcurrent: true,
action: async (context) => ({
type: 'message',
messageType: 'info',
content: getStatusMessage(context),
}),
};
const enableCommand: SlashCommand = {
name: 'on',
altNames: ['enable'],
description: 'Enable offline mode',
kind: CommandKind.BUILT_IN,
autoExecute: true,
isSafeConcurrent: true,
action: async (context) => ({
type: 'message',
messageType: 'info',
content: await setOfflineMode(context, true),
}),
};
const disableCommand: SlashCommand = {
name: 'off',
altNames: ['disable'],
description: 'Disable offline mode',
kind: CommandKind.BUILT_IN,
autoExecute: true,
isSafeConcurrent: true,
action: async (context) => ({
type: 'message',
messageType: 'info',
content: await setOfflineMode(context, false),
}),
};
export const offlineCommand: SlashCommand = {
name: 'offline',
description: 'Manage offline mode and cloud delegation behavior',
kind: CommandKind.BUILT_IN,
autoExecute: false,
isSafeConcurrent: true,
subCommands: [statusCommand, enableCommand, disableCommand],
action: async (context) => ({
type: 'message',
messageType: 'info',
content: getStatusMessage(context),
}),
};
@@ -0,0 +1,43 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text } from 'ink';
import { usePulsingColor } from '../hooks/usePulsingColor.js';
interface PulsingDotProps {
/** Full-brightness color */
color: string;
/** Dim color at the trough of the pulse */
dimColor: string;
/** Duration of one full pulse cycle in ms */
cycleDurationMs: number;
/** Whether the dot is actively pulsing. When false, renders static at full color. */
active: boolean;
/** Optional label text rendered after the dot */
label?: string;
}
export const PulsingDot: React.FC<PulsingDotProps> = ({
color,
dimColor,
cycleDurationMs,
active,
label,
}) => {
const currentColor = usePulsingColor(
color,
dimColor,
cycleDurationMs,
active,
);
return (
<Text color={currentColor}>
{active ? '◉' : '●'} {label}
</Text>
);
};
@@ -34,6 +34,7 @@ describe('<StatusRow />', () => {
contextFileNames: [],
showApprovalModeIndicator: ApprovalMode.DEFAULT,
allowPlanMode: false,
isOfflineMode: false,
renderMarkdown: true,
currentModel: 'gemini-3',
};
@@ -46,6 +47,8 @@ describe('<StatusRow />', () => {
showWit: true,
modeContentObj: null,
showMinimalContext: false,
isOfflineMode: false,
cloudSubagentActive: false,
});
const uiState: Partial<UIState> = {
@@ -86,6 +89,8 @@ describe('<StatusRow />', () => {
showWit: false,
modeContentObj: null,
showMinimalContext: false,
isOfflineMode: false,
cloudSubagentActive: false,
});
const { lastFrame, waitUntilReady } = await renderWithProviders(
@@ -115,6 +120,8 @@ describe('<StatusRow />', () => {
showWit: true,
modeContentObj: null,
showMinimalContext: false,
isOfflineMode: false,
cloudSubagentActive: false,
});
const uiState: Partial<UIState> = {
@@ -140,4 +147,79 @@ describe('<StatusRow />', () => {
await waitUntilReady();
expect(lastFrame()).toContain('Tip: Test Tip');
});
it('renders offline mode indicator in detailed UI', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: false,
showLoadingIndicator: false,
showTips: false,
showWit: false,
modeContentObj: null,
showMinimalContext: false,
isOfflineMode: true,
cloudSubagentActive: false,
});
const uiState: Partial<UIState> = {
...defaultUiState,
isOfflineMode: true,
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={true}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 100,
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('offline');
});
it('renders cloud indicator when cloud subagent is active', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: false,
showLoadingIndicator: false,
showTips: false,
showWit: false,
modeContentObj: null,
showMinimalContext: false,
isOfflineMode: true,
cloudSubagentActive: true,
});
const uiState: Partial<UIState> = {
...defaultUiState,
isOfflineMode: true,
cloudSubagentActive: true,
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={true}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 100,
uiState,
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('offline');
expect(output).toContain('cloud');
});
});
+34 -1
View File
@@ -12,7 +12,7 @@ import {
type ThoughtSummary,
} from '@google/gemini-cli-core';
import stripAnsi from 'strip-ansi';
import { type ActiveHook } from '../types.js';
import { type ActiveHook, StreamingState } from '../types.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { theme } from '../semantic-colors.js';
@@ -25,7 +25,9 @@ import { HorizontalLine } from './shared/HorizontalLine.js';
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
import { ShellModeIndicator } from './ShellModeIndicator.js';
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
import { PulsingDot } from './PulsingDot.js';
import { useComposerStatus } from '../hooks/useComposerStatus.js';
import { useStreamingContext } from '../contexts/StreamingContext.js';
/**
* Layout constants to prevent magic numbers.
@@ -173,7 +175,14 @@ export const StatusRow: React.FC<StatusRowProps> = ({
showWit,
modeContentObj,
showMinimalContext,
isOfflineMode,
cloudSubagentActive,
} = useComposerStatus();
const streamingState = useStreamingContext();
const isLocalActive =
isOfflineMode &&
streamingState === StreamingState.Responding &&
!cloudSubagentActive;
const [statusWidth, setStatusWidth] = useState(0);
const [tipWidth, setTipWidth] = useState(0);
@@ -411,6 +420,30 @@ export const StatusRow: React.FC<StatusRowProps> = ({
<RawMarkdownIndicator />
</Box>
)}
{isOfflineMode && (
<Box
marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}
flexDirection="row"
gap={1}
>
<PulsingDot
color={theme.status.success}
dimColor={theme.ui.dark}
cycleDurationMs={1500}
active={isLocalActive}
label="offline"
/>
{cloudSubagentActive && (
<PulsingDot
color={theme.status.warning}
dimColor={theme.ui.dark}
cycleDurationMs={800}
active={true}
label="cloud"
/>
)}
</Box>
)}
</>
) : (
showRow2Minimal &&
@@ -62,18 +62,25 @@ export const SubagentProgressDisplay: React.FC<
let headerText: string | undefined;
let headerColor = theme.text.secondary;
const isCloud =
progress.agentName === 'cloud-subagent' ||
progress.agentName === 'cloud_subagent';
const prefix = isCloud ? '☁ Cloud' : `Subagent ${progress.agentName}`;
if (progress.state === 'cancelled') {
headerText = `Subagent ${progress.agentName} was cancelled.`;
headerText = `${prefix} was cancelled.`;
headerColor = theme.status.warning;
} else if (progress.state === 'error') {
headerText = `Subagent ${progress.agentName} failed.`;
headerText = `${prefix} failed.`;
headerColor = theme.status.error;
} else if (progress.state === 'completed') {
headerText = `Subagent ${progress.agentName} completed.`;
headerText = `${prefix} completed.`;
headerColor = theme.status.success;
} else {
headerText = `Running subagent ${progress.agentName}...`;
headerColor = theme.text.primary;
headerText = isCloud
? `☁ Running cloud subagent...`
: `Running subagent ${progress.agentName}...`;
headerColor = isCloud ? theme.status.warning : theme.text.primary;
}
return (
@@ -97,6 +97,33 @@ describe('ToolConfirmationMessage', () => {
unmount();
});
it('should use allow/always allow/deny labels for cloud-subagent confirmations', async () => {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'info',
title: '☁ Delegate to cloud subagent',
prompt:
'This will run with full tool access in cloud mode.\n\nAnalyze migration risks across the codebase.',
};
const { lastFrame, unmount } = await renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
confirmationDetails={confirmationDetails}
config={mockConfig}
getPreferredEditor={vi.fn()}
availableTerminalHeight={30}
terminalWidth={80}
toolName="cloud-subagent"
/>,
);
const output = lastFrame();
expect(output).toContain('1. Allow');
expect(output).toContain('2. Always allow');
expect(output).toContain('3. Deny (esc)');
unmount();
});
it('should display WarningMessage for deceptive URLs in info type', async () => {
const confirmationDetails: SerializableConfirmationDetails = {
type: 'info',
@@ -371,29 +371,46 @@ export const ToolConfirmationMessage: React.FC<
key: 'No, suggest changes (esc)',
});
} else if (confirmationDetails.type === 'info') {
const isCloudSubagentConfirmation =
toolName === 'cloud-subagent' ||
toolName === 'cloud_subagent' ||
confirmationDetails.title?.includes('cloud subagent');
options.push({
label: 'Allow once',
label: isCloudSubagentConfirmation ? 'Allow' : 'Allow once',
value: ToolConfirmationOutcome.ProceedOnce,
key: 'Allow once',
key: isCloudSubagentConfirmation ? 'Allow' : 'Allow once',
});
if (isTrustedFolder) {
options.push({
label: 'Allow for this session',
label: isCloudSubagentConfirmation
? 'Always allow'
: 'Allow for this session',
value: ToolConfirmationOutcome.ProceedAlways,
key: 'Allow for this session',
key: isCloudSubagentConfirmation
? 'Always allow'
: 'Allow for this session',
});
if (allowPermanentApproval) {
options.push({
label: 'Allow for all future sessions',
label: isCloudSubagentConfirmation
? 'Always allow for all future sessions'
: 'Allow for all future sessions',
value: ToolConfirmationOutcome.ProceedAlwaysAndSave,
key: 'Allow for all future sessions',
key: isCloudSubagentConfirmation
? 'Always allow for all future sessions'
: 'Allow for all future sessions',
});
}
}
options.push({
label: 'No, suggest changes (esc)',
label: isCloudSubagentConfirmation
? 'Deny (esc)'
: 'No, suggest changes (esc)',
value: ToolConfirmationOutcome.Cancel,
key: 'No, suggest changes (esc)',
key: isCloudSubagentConfirmation
? 'Deny (esc)'
: 'No, suggest changes (esc)',
});
} else if (confirmationDetails.type === 'mcp') {
options.push({
@@ -433,6 +450,7 @@ export const ToolConfirmationMessage: React.FC<
allowPermanentApproval,
config,
isDiffingEnabled,
toolName,
]);
const availableBodyContentHeight = useCallback(() => {
@@ -8,7 +8,7 @@ exports[`ToolConfirmationMessage > enablePermanentToolApproval setting > should
╰──────────────────────────────────────────────────────────────────────────────╯
Apply this change?
● 1. Allow once
● 1. Allow once
2. Allow for this session
3. Allow for this file in all future sessions ~/.gemini/policies/auto-saved.toml
4. Modify with external editor
@@ -16,92 +16,6 @@ Apply this change?
"
`;
exports[`ToolConfirmationMessage > height allocation and layout > should expand to available height for large edit diffs 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ... 10 hidden (Ctrl+O) ... │
│ 6 - const oldLine6 = true; │
│ 6 + const newLine6 = true; │
│ 7 - const oldLine7 = true; │
│ 7 + const newLine7 = true; │
│ 8 - const oldLine8 = true; │
│ 8 + const newLine8 = true; │
│ 9 - const oldLine9 = true; │
│ 9 + const newLine9 = true; │
│ 10 - const oldLine10 = true; │
│ 10 + const newLine10 = true; │
│ 11 - const oldLine11 = true; │
│ 11 + const newLine11 = true; │
│ 12 - const oldLine12 = true; │
│ 12 + const newLine12 = true; │
│ 13 - const oldLine13 = true; │
│ 13 + const newLine13 = true; │
│ 14 - const oldLine14 = true; │
│ 14 + const newLine14 = true; │
│ 15 - const oldLine15 = true; │
│ 15 + const newLine15 = true; │
│ 16 - const oldLine16 = true; │
│ 16 + const newLine16 = true; │
│ 17 - const oldLine17 = true; │
│ 17 + const newLine17 = true; │
│ 18 - const oldLine18 = true; │
│ 18 + const newLine18 = true; │
│ 19 - const oldLine19 = true; │
│ 19 + const newLine19 = true; │
│ 20 - const oldLine20 = true; │
│ 20 + const newLine20 = true; │
╰──────────────────────────────────────────────────────────────────────────────╯
Apply this change?
● 1. Allow once
2. Allow for this session
3. Modify with external editor
4. No, suggest changes (esc)
"
`;
exports[`ToolConfirmationMessage > height allocation and layout > should expand to available height for large exec commands 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ... 19 hidden (Ctrl+O) ... │
│ echo "Line 20" │
│ echo "Line 21" │
│ echo "Line 22" │
│ echo "Line 23" │
│ echo "Line 24" │
│ echo "Line 25" │
│ echo "Line 26" │
│ echo "Line 27" │
│ echo "Line 28" │
│ echo "Line 29" │
│ echo "Line 30" │
│ echo "Line 31" │
│ echo "Line 32" │
│ echo "Line 33" │
│ echo "Line 34" │
│ echo "Line 35" │
│ echo "Line 36" │
│ echo "Line 37" │
│ echo "Line 38" │
│ echo "Line 39" │
│ echo "Line 40" │
│ echo "Line 41" │
│ echo "Line 42" │
│ echo "Line 43" │
│ echo "Line 44" │
│ echo "Line 45" │
│ echo "Line 46" │
│ echo "Line 47" │
│ echo "Line 48" │
│ echo "Line 49" │
│ echo "Line 50" │
╰──────────────────────────────────────────────────────────────────────────────╯
Allow execution of [echo]?
● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
"
`;
exports[`ToolConfirmationMessage > should display multiple commands for exec type when provided 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ echo "hello" │
@@ -112,7 +26,7 @@ exports[`ToolConfirmationMessage > should display multiple commands for exec typ
╰──────────────────────────────────────────────────────────────────────────────╯
Allow execution of [echo, ls, whoami]?
● 1. Allow once
● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
"
@@ -125,7 +39,7 @@ URLs to fetch:
- https://raw.githubusercontent.com/google/gemini-react/main/README.md
Do you want to proceed?
● 1. Allow once
● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
"
@@ -135,32 +49,18 @@ exports[`ToolConfirmationMessage > should not display urls if prompt and url are
"https://example.com
Do you want to proceed?
● 1. Allow once
● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
"
`;
exports[`ToolConfirmationMessage > should render multiline shell scripts with correct newlines and syntax highlighting 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ echo "hello" │
│ for i in 1 2 3; do │
│ echo $i │
│ done │
╰──────────────────────────────────────────────────────────────────────────────╯
Allow execution of [echo]?
● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)"
`;
exports[`ToolConfirmationMessage > should strip BiDi characters from MCP tool and server names 1`] = `
"MCP Server: testserver
Tool: testtool
Allow execution of MCP tool "testtool" from server "testserver"?
● 1. Allow once
● 1. Allow once
2. Allow tool for this session
3. Allow all server tools for this session
4. No, suggest changes (esc)
@@ -175,7 +75,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations'
╰──────────────────────────────────────────────────────────────────────────────╯
Apply this change?
● 1. Allow once
● 1. Allow once
2. Modify with external editor
3. No, suggest changes (esc)
"
@@ -189,7 +89,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations'
╰──────────────────────────────────────────────────────────────────────────────╯
Apply this change?
● 1. Allow once
● 1. Allow once
2. Allow for this session
3. Modify with external editor
4. No, suggest changes (esc)
@@ -202,7 +102,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations'
╰──────────────────────────────────────────────────────────────────────────────╯
Allow execution of [echo]?
● 1. Allow once
● 1. Allow once
2. No, suggest changes (esc)
"
`;
@@ -213,7 +113,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations'
╰──────────────────────────────────────────────────────────────────────────────╯
Allow execution of [echo]?
● 1. Allow once
● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
"
@@ -223,7 +123,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for info confirmations'
"https://example.com
Do you want to proceed?
● 1. Allow once
● 1. Allow once
2. No, suggest changes (esc)
"
`;
@@ -232,7 +132,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for info confirmations'
"https://example.com
Do you want to proceed?
● 1. Allow once
● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
"
@@ -243,7 +143,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for mcp confirmations' >
Tool: test-tool
Allow execution of MCP tool "test-tool" from server "test-server"?
● 1. Allow once
● 1. Allow once
2. No, suggest changes (esc)
"
`;
@@ -253,7 +153,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for mcp confirmations' >
Tool: test-tool
Allow execution of MCP tool "test-tool" from server "test-server"?
● 1. Allow once
● 1. Allow once
2. Allow tool for this session
3. Allow all server tools for this session
4. No, suggest changes (esc)
@@ -157,6 +157,8 @@ export interface UIState {
queueErrorMessage: string | null;
showApprovalModeIndicator: ApprovalMode;
allowPlanMode: boolean;
isOfflineMode?: boolean;
cloudSubagentActive?: boolean;
currentModel: string;
contextFileNames: string[];
errorCount: number;
+42 -10
View File
@@ -21,6 +21,8 @@ export const useComposerStatus = () => {
const uiState = useUIState();
const quotaState = useQuotaState();
const settings = useSettings();
const isOfflineMode = Boolean(uiState.isOfflineMode);
const cloudSubagentActive = Boolean(uiState.cloudSubagentActive);
const hasPendingToolConfirmation = useMemo(
() =>
@@ -64,22 +66,50 @@ export const useComposerStatus = () => {
if (hideMinimalModeHintWhileBusy) return null;
switch (showApprovalModeIndicator) {
case ApprovalMode.YOLO:
return { text: 'YOLO', color: theme.status.error };
case ApprovalMode.PLAN:
return { text: 'plan', color: theme.status.success };
case ApprovalMode.AUTO_EDIT:
return { text: 'auto edit', color: theme.status.warning };
case ApprovalMode.DEFAULT:
default:
return null;
const approvalModeIndicator = (() => {
switch (showApprovalModeIndicator) {
case ApprovalMode.YOLO:
return { text: 'YOLO', color: theme.status.error };
case ApprovalMode.PLAN:
return { text: 'plan', color: theme.status.success };
case ApprovalMode.AUTO_EDIT:
return { text: 'auto edit', color: theme.status.warning };
case ApprovalMode.DEFAULT:
default:
return null;
}
})();
if (approvalModeIndicator) {
const suffix = cloudSubagentActive
? ' + cloud'
: isOfflineMode
? ' + offline'
: '';
return suffix
? {
text: `${approvalModeIndicator.text}${suffix}`,
color: approvalModeIndicator.color,
}
: approvalModeIndicator;
}
if (cloudSubagentActive) {
return { text: 'cloud', color: theme.status.warning };
}
if (isOfflineMode) {
return { text: 'offline', color: theme.status.success };
}
return null;
}, [
uiState.cleanUiDetailsVisible,
showLoadingIndicator,
uiState.activeHooks.length,
showApprovalModeIndicator,
isOfflineMode,
cloudSubagentActive,
]);
const showMinimalContext = isContextUsageHigh(
@@ -108,5 +138,7 @@ export const useComposerStatus = () => {
showWit,
modeContentObj,
showMinimalContext,
isOfflineMode,
cloudSubagentActive,
};
};
@@ -0,0 +1,45 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect } from 'react';
import { interpolateColor } from '../themes/color-utils.js';
const FRAME_INTERVAL_MS = 60; // ~16fps — smooth enough for a pulse, cheap on CPU
/**
* Returns a color that pulses between `activeColor` and `dimColor` on a sine
* curve. When `active` is false the hook stops its timer and returns
* `activeColor` at full brightness (static dot).
*/
export function usePulsingColor(
activeColor: string,
dimColor: string,
cycleDurationMs: number,
active: boolean,
): string {
const [time, setTime] = useState(0);
useEffect(() => {
if (!active) {
setTime(0);
return;
}
const interval = setInterval(() => {
setTime((prev) => prev + FRAME_INTERVAL_MS);
}, FRAME_INTERVAL_MS);
return () => clearInterval(interval);
}, [active]);
if (!active) {
return activeColor;
}
// Sine oscillation: 0 → 1 → 0 over one cycle
const progress = (Math.sin((2 * Math.PI * time) / cycleDurationMs) + 1) / 2;
return interpolateColor(dimColor, activeColor, progress) || activeColor;
}
+58 -1
View File
@@ -7,13 +7,17 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AgentTool } from './agent-tool.js';
import { makeFakeConfig } from '../test-utils/config.js';
import { createMockMessageBus } from '../test-utils/mock-message-bus.js';
import {
createMockMessageBus,
getMockMessageBusInstance,
} from '../test-utils/mock-message-bus.js';
import type { Config } from '../config/config.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { LocalSubagentInvocation } from './local-invocation.js';
import { RemoteAgentInvocation } from './remote-invocation.js';
import { BrowserAgentInvocation } from './browser/browserAgentInvocation.js';
import { BROWSER_AGENT_NAME } from './browser/browserAgentDefinition.js';
import { CLOUD_SUBAGENT_NAME } from './cloud-subagent.js';
import { AgentRegistry } from './registry.js';
import type { LocalAgentDefinition, RemoteAgentDefinition } from './types.js';
@@ -54,6 +58,25 @@ describe('AgentTool', () => {
agentCardUrl: 'http://example.com/agent',
};
const cloudSubagentDefinition: LocalAgentDefinition = {
kind: 'local',
name: CLOUD_SUBAGENT_NAME,
displayName: 'cloud-subagent',
description: 'Cloud delegation specialist.',
inputConfig: {
inputSchema: {
type: 'object',
properties: {
request: { type: 'string' },
},
required: ['request'],
},
},
modelConfig: { model: 'test', generateContentConfig: {} },
runConfig: { maxTimeMinutes: 1 },
promptConfig: { systemPrompt: 'test' },
};
beforeEach(() => {
vi.clearAllMocks();
mockConfig = makeFakeConfig();
@@ -67,6 +90,7 @@ describe('AgentTool', () => {
vi.spyOn(registry, 'getDefinition').mockImplementation((name: string) => {
if (name === 'TestLocalAgent') return testLocalDefinition;
if (name === 'TestRemoteAgent') return testRemoteDefinition;
if (name === CLOUD_SUBAGENT_NAME) return cloudSubagentDefinition;
if (name === BROWSER_AGENT_NAME) {
return {
kind: 'remote',
@@ -141,4 +165,37 @@ describe('AgentTool', () => {
'Invoke Browser Agent',
);
});
it('should use concise cloud-subagent description text', () => {
const params = {
agent_name: CLOUD_SUBAGENT_NAME,
prompt: 'Analyze all package-level config and summarize migration risks.',
};
const invocation = tool['createInvocation'](params, mockMessageBus);
const description = invocation.getDescription();
expect(description).toBe(
'Delegating to cloud-subagent for complex cloud execution',
);
});
it('should return custom confirmation details for cloud-subagent', async () => {
getMockMessageBusInstance(mockMessageBus).defaultToolDecision = 'ask_user';
const params = {
agent_name: CLOUD_SUBAGENT_NAME,
prompt: 'Summarize risk hotspots and propose migration sequencing.',
};
const invocation = tool['createInvocation'](params, mockMessageBus);
const result = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(result).toMatchObject({
type: 'info',
title: '☁ Delegate to cloud subagent',
});
// Should NOT delegate to child invocation for confirmation
expect(LocalSubagentInvocation).not.toHaveBeenCalled();
});
});
+72 -6
View File
@@ -29,6 +29,21 @@ import {
GEN_AI_AGENT_NAME,
} from '../telemetry/constants.js';
import { AGENT_TOOL_NAME } from '../tools/tool-names.js';
import { CLOUD_SUBAGENT_NAME } from './cloud-subagent.js';
import { coreEvents } from '../utils/events.js';
const CLOUD_DELEGATION_PROMPT_MAX_LENGTH = 280;
function truncateText(value: unknown, maxLength: number): string {
if (typeof value !== 'string' || !value.trim()) {
return 'Cloud delegation requested.';
}
const normalized = value.replace(/\s+/g, ' ').trim();
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, maxLength - 3)}...`;
}
/**
* A unified tool for invoking subagents.
@@ -144,6 +159,9 @@ class DelegateInvocation extends BaseToolInvocation<
}
getDescription(): string {
if (this.definition.name === CLOUD_SUBAGENT_NAME) {
return 'Delegating to cloud-subagent for complex cloud execution';
}
return `Delegating to agent '${this.definition.name}'`;
}
@@ -180,15 +198,45 @@ class DelegateInvocation extends BaseToolInvocation<
override async shouldConfirmExecute(
abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
if (this.definition.name === CLOUD_SUBAGENT_NAME) {
return super.shouldConfirmExecute(abortSignal);
}
const hintedParams = this.withUserHints(this.mappedInputs);
const invocation = this.buildChildInvocation(hintedParams);
return invocation.shouldConfirmExecute(abortSignal);
}
protected override async getConfirmationDetails(
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
if (this.definition.name !== CLOUD_SUBAGENT_NAME) {
return false;
}
const prompt = truncateText(
this.mappedInputs['request'] ?? this.params.prompt,
CLOUD_DELEGATION_PROMPT_MAX_LENGTH,
);
return {
type: 'info',
title: '☁ Delegate to cloud subagent',
prompt: `This will run with full tool access in cloud mode.\n\n${prompt}`,
onConfirm: async (_outcome) => {
// Policy updates are handled centrally by the scheduler.
},
};
}
async execute(options: ExecuteOptions): Promise<ToolResult> {
const { abortSignal: signal, updateOutput } = options;
const hintedParams = this.withUserHints(this.mappedInputs);
const invocation = this.buildChildInvocation(hintedParams);
const isCloud = this.definition.name === CLOUD_SUBAGENT_NAME;
if (isCloud) {
coreEvents.emitCloudSubagentExecution(this.definition.name, 'started');
}
return runInDevTraceSpan(
{
@@ -202,12 +250,30 @@ class DelegateInvocation extends BaseToolInvocation<
},
async ({ metadata }) => {
metadata.input = this.params;
const result = await invocation.execute({
abortSignal: signal,
updateOutput,
});
metadata.output = result;
return result;
try {
const result = await invocation.execute({
abortSignal: signal,
updateOutput,
});
metadata.output = result;
if (isCloud) {
coreEvents.emitCloudSubagentExecution(
this.definition.name,
'ended',
'success',
);
}
return result;
} catch (error) {
if (isCloud) {
coreEvents.emitCloudSubagentExecution(
this.definition.name,
'ended',
signal.aborted ? 'cancelled' : 'error',
);
}
throw error;
}
},
);
}
@@ -0,0 +1,52 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CloudSubagent, CLOUD_SUBAGENT_NAME } from './cloud-subagent.js';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import { getCoreSystemPrompt } from '../core/prompts.js';
vi.mock('../core/prompts.js', () => ({
getCoreSystemPrompt: vi.fn().mockReturnValue('BASE PROMPT'),
}));
describe('CloudSubagent', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should lazily build promptConfig without eager system prompt rendering', () => {
const config = { sessionId: 'test' };
const context = {
config,
toolRegistry: undefined,
} as unknown as AgentLoopContext;
const agent = CloudSubagent(context);
expect(getCoreSystemPrompt).not.toHaveBeenCalled();
const promptConfig = agent.promptConfig;
expect(getCoreSystemPrompt).toHaveBeenCalledWith(config, undefined, false);
expect(promptConfig.systemPrompt).toContain('Cloud Delegation Protocol');
});
it('should exclude itself from the available tool list', () => {
const config = { sessionId: 'test' };
const context = {
config,
toolRegistry: {
getAllToolNames: vi
.fn()
.mockReturnValue(['read_file', CLOUD_SUBAGENT_NAME, 'shell']),
},
} as unknown as AgentLoopContext;
const agent = CloudSubagent(context);
expect(agent.toolConfig?.tools).toEqual(['read_file', 'shell']);
});
});
@@ -0,0 +1,87 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { z } from 'zod';
import type { AgentLoopContext } from '../config/agent-loop-context.js';
import { getCoreSystemPrompt } from '../core/prompts.js';
import type { LocalAgentDefinition } from './types.js';
export const CLOUD_SUBAGENT_NAME = 'cloud_subagent';
const CloudSubagentOutputSchema = z.object({
summary: z
.string()
.describe(
'A polished summary of findings, decisions, and outcomes from the delegated cloud task.',
),
});
export const CloudSubagent = (
context: AgentLoopContext,
): LocalAgentDefinition<typeof CloudSubagentOutputSchema> => ({
kind: 'local',
name: CLOUD_SUBAGENT_NAME,
displayName: 'cloud-subagent',
description:
'Delegation specialist for complex or high-context tasks while offline mode is enabled. Use when work is likely to be long-running, high-volume, or exploratory, then return a crisp and elegant summary.',
inputConfig: {
inputSchema: {
type: 'object',
properties: {
request: {
type: 'string',
description:
'The delegated task to execute in the cloud context. Include both what to do and why cloud delegation is justified.',
},
},
required: ['request'],
},
},
outputConfig: {
outputName: 'result',
description: 'A concise but eloquent summary of the delegated task result.',
schema: CloudSubagentOutputSchema,
},
processOutput: (output) => output.summary,
modelConfig: {
model: 'inherit',
},
get toolConfig() {
const tools = (context.toolRegistry?.getAllToolNames() ?? []).filter(
(toolName) => toolName !== CLOUD_SUBAGENT_NAME,
);
return {
tools,
};
},
get promptConfig() {
return {
query: '${request}',
systemPrompt: `${getCoreSystemPrompt(
context.config,
/* useMemory */ undefined,
/* interactiveOverride */ false,
)}
# Cloud Delegation Protocol
- You are the dedicated cloud execution specialist.
- Prioritize complex, high-volume, or exploratory work delegated by the main offline-mode agent.
- Execute thoroughly, but keep the final answer compact and structured.
- Your final summary must be elegant and useful:
- Outcome first.
- Key findings and decisions second.
- Important caveats or follow-ups last.
- Avoid unnecessary verbosity and avoid exposing internal deliberation.
You MUST call \`complete_task\` with a JSON object containing the \`summary\`.`,
};
},
runConfig: {
maxTimeMinutes: 15,
maxTurns: 25,
},
});
+8 -2
View File
@@ -14,6 +14,7 @@ import { loadAgentsFromDirectory } from './agentLoader.js';
import { CodebaseInvestigatorAgent } from './codebase-investigator.js';
import { CliHelpAgent } from './cli-help-agent.js';
import { GeneralistAgent } from './generalist-agent.js';
import { CloudSubagent, CLOUD_SUBAGENT_NAME } from './cloud-subagent.js';
import { BrowserAgentDefinition } from './browser/browserAgentDefinition.js';
import { MemoryManagerAgent } from './memory-manager-agent.js';
import { AgentTool } from './agent-tool.js';
@@ -266,6 +267,9 @@ export class AgentRegistry {
this.registerLocalAgent(CodebaseInvestigatorAgent(this.config));
this.registerLocalAgent(CliHelpAgent(this.config));
this.registerLocalAgent(GeneralistAgent(this.config));
if (this.config.isOfflineModeEnabled()) {
this.registerLocalAgent(CloudSubagent(this.config));
}
// Register the browser agent if enabled in settings.
// Tools are configured dynamically at invocation time via browserAgentFactory.
@@ -391,8 +395,10 @@ export class AgentRegistry {
return;
}
// Only add override for remote agents. Local agents are handled by blanket allow.
if (definition.kind === 'remote') {
// Only add override for remote agents and cloud subagent.
// Local agents are handled by blanket allow, but cloud subagent needs
// explicit ASK_USER since it delegates work to a cloud model.
if (definition.kind === 'remote' || definition.name === CLOUD_SUBAGENT_NAME) {
policyEngine.addRule({
toolName: AgentTool.Name,
argsPattern: new RegExp(`"agent_name":\\s*"${definition.name}"`),
+43
View File
@@ -199,6 +199,7 @@ vi.mock('../resources/resource-registry.js', () => ({
const mockCoreEvents = vi.hoisted(() => ({
emitFeedback: vi.fn(),
emitModelChanged: vi.fn(),
emitOfflineModeChanged: vi.fn(),
emitConsoleLog: vi.fn(),
emitQuotaChanged: vi.fn(),
on: vi.fn(),
@@ -1849,6 +1850,48 @@ describe('GemmaModelRouterSettings', () => {
});
});
describe('OfflineSettings', () => {
const baseParams: ConfigParameters = {
sessionId: 'test-offline',
targetDir: '.',
debugMode: false,
model: DEFAULT_GEMINI_MODEL,
cwd: '.',
};
it('should default offline mode to disabled when not provided', () => {
const config = new Config(baseParams);
expect(config.isOfflineModeEnabled()).toBe(false);
expect(config.getOfflineSettings().localModelRouting).toBe(
'stub_default_api',
);
});
it('should use provided offline settings', () => {
const config = new Config({
...baseParams,
offline: {
enabled: true,
localModelRouting: 'stub_default_api',
},
});
expect(config.isOfflineModeEnabled()).toBe(true);
expect(config.getOfflineSettings()).toEqual({
enabled: true,
localModelRouting: 'stub_default_api',
});
});
it('should emit offline mode change events when toggled', async () => {
const config = new Config(baseParams);
await config.setOfflineMode(true);
expect(mockCoreEvents.emitOfflineModeChanged).toHaveBeenCalledWith(true);
expect(config.isOfflineModeEnabled()).toBe(true);
});
});
describe('setApprovalMode with folder trust', () => {
const baseParams: ConfigParameters = {
sessionId: 'test',
+44
View File
@@ -200,6 +200,13 @@ export interface PlanSettings {
modelRouting?: boolean;
}
export type OfflineLocalModelRouting = 'stub_default_api';
export interface OfflineSettings {
enabled?: boolean;
localModelRouting?: OfflineLocalModelRouting;
}
export interface TelemetrySettings {
enabled?: boolean;
target?: TelemetryTarget;
@@ -710,6 +717,7 @@ export interface ConfigParameters {
disableLLMCorrection?: boolean;
plan?: boolean;
tracker?: boolean;
offline?: OfflineSettings;
planSettings?: PlanSettings;
worktreeSettings?: WorktreeSettings;
modelSteering?: boolean;
@@ -946,6 +954,10 @@ export class Config implements McpContext, AgentLoopContext {
private readonly disableLLMCorrection: boolean;
private readonly planEnabled: boolean;
private readonly trackerEnabled: boolean;
private offlineSettings: {
enabled: boolean;
localModelRouting: OfflineLocalModelRouting;
};
private readonly planModeRoutingEnabled: boolean;
private readonly modelSteering: boolean;
private memoryContextManager?: MemoryContextManager;
@@ -1095,6 +1107,11 @@ export class Config implements McpContext, AgentLoopContext {
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
this.planEnabled = params.plan ?? true;
this.trackerEnabled = params.tracker ?? false;
this.offlineSettings = {
enabled: params.offline?.enabled ?? false,
localModelRouting:
params.offline?.localModelRouting ?? 'stub_default_api',
};
this.planModeRoutingEnabled = params.planSettings?.modelRouting ?? true;
this.enableEventDrivenScheduler = params.enableEventDrivenScheduler ?? true;
this.skillsSupport = params.skillsSupport ?? true;
@@ -2886,6 +2903,17 @@ export class Config implements McpContext, AgentLoopContext {
return this.directWebFetch;
}
isOfflineModeEnabled(): boolean {
return this.offlineSettings.enabled;
}
getOfflineSettings(): {
enabled: boolean;
localModelRouting: OfflineLocalModelRouting;
} {
return { ...this.offlineSettings };
}
setApprovedPlanPath(path: string | undefined): void {
this.approvedPlanPath = path;
}
@@ -2945,6 +2973,22 @@ export class Config implements McpContext, AgentLoopContext {
this.ideMode = value;
}
async setOfflineMode(enabled: boolean): Promise<void> {
if (this.offlineSettings.enabled === enabled) {
return;
}
this.offlineSettings.enabled = enabled;
coreEvents.emitOfflineModeChanged(enabled);
if (!this.initialized) {
return;
}
await this.agentRegistry.reload();
this.updateSystemInstructionIfInitialized();
}
/**
* Get the current FileSystemService
*/
@@ -72,6 +72,11 @@ describe('PromptProvider', () => {
isInteractiveShellEnabled: vi.fn().mockReturnValue(true),
isTopicUpdateNarrationEnabled: vi.fn().mockReturnValue(false),
isMemoryManagerEnabled: vi.fn().mockReturnValue(false),
isOfflineModeEnabled: vi.fn().mockReturnValue(false),
getOfflineSettings: vi.fn().mockReturnValue({
enabled: false,
localModelRouting: 'stub_default_api',
}),
getSkillManager: vi.fn().mockReturnValue({
getSkills: vi.fn().mockReturnValue([]),
}),
@@ -156,6 +161,39 @@ describe('PromptProvider', () => {
);
});
it('should include offline strategy section when offline mode is enabled', () => {
vi.mocked(mockConfig.isOfflineModeEnabled).mockReturnValue(true);
vi.mocked(mockConfig.getOfflineSettings).mockReturnValue({
enabled: true,
localModelRouting: 'stub_default_api',
});
const provider = new PromptProvider();
const prompt = provider.getCoreSystemPrompt(mockConfig);
expect(prompt).toContain('# Offline Mode Strategy');
expect(prompt).toContain('cloud_subagent');
expect(prompt).toContain('stub_default_api');
});
it('should omit offline strategy section when offline mode is disabled', () => {
vi.mocked(mockConfig.isOfflineModeEnabled).mockReturnValue(false);
const provider = new PromptProvider();
const prompt = provider.getCoreSystemPrompt(mockConfig);
expect(prompt).not.toContain('# Offline Mode Strategy');
});
it('should not throw when tool registry is not initialized', () => {
vi.mocked(mockConfig.getToolRegistry).mockReturnValue(
undefined as unknown as ToolRegistry,
);
const provider = new PromptProvider();
expect(() => provider.getCoreSystemPrompt(mockConfig)).not.toThrow();
});
describe('plan mode prompt', () => {
const mockMessageBus = {
publish: vi.fn(),
+16 -2
View File
@@ -56,7 +56,8 @@ export class PromptProvider {
const isPlanMode = approvalMode === ApprovalMode.PLAN;
const isYoloMode = approvalMode === ApprovalMode.YOLO;
const skills = context.config.getSkillManager().getSkills();
const toolNames = context.toolRegistry.getAllToolNames();
const toolRegistry = context.toolRegistry;
const toolNames = toolRegistry?.getAllToolNames?.() ?? [];
const enabledToolNames = new Set(toolNames);
const approvedPlanPath = context.config.getApprovedPlanPath();
@@ -85,7 +86,7 @@ export class PromptProvider {
// --- Context Gathering ---
let planModeToolsList = '';
if (isPlanMode) {
const allTools = context.toolRegistry.getAllTools();
const allTools = toolRegistry?.getAllTools?.() ?? [];
planModeToolsList = allTools
.map((t) => {
if (t instanceof DiscoveredMCPTool) {
@@ -129,6 +130,11 @@ export class PromptProvider {
(!!userMemory.global?.trim() ||
!!userMemory.extension?.trim() ||
!!userMemory.project?.trim());
const offlineModeEnabled =
context.config.isOfflineModeEnabled?.() ?? false;
const offlineSettings = context.config.getOfflineSettings?.() ?? {
localModelRouting: 'stub_default_api',
};
const options: snippets.SystemPromptOptions = {
preamble: this.withSection('preamble', () => ({
@@ -141,6 +147,14 @@ export class PromptProvider {
contextFilenames,
topicUpdateNarration: context.config.isTopicUpdateNarrationEnabled(),
})),
offlineMode: this.withSection(
'offlineMode',
() => ({
cloudSubagentName: 'cloud_subagent',
localModelRouting: offlineSettings.localModelRouting,
}),
offlineModeEnabled,
),
subAgents: this.withSection(
'agentContexts',
() =>
@@ -33,6 +33,7 @@ import {
export interface SystemPromptOptions {
preamble?: PreambleOptions;
coreMandates?: CoreMandatesOptions;
offlineMode?: OfflineModeOptions;
subAgents?: SubAgentOptions[];
agentSkills?: AgentSkillOptions[];
hookContext?: boolean;
@@ -109,6 +110,11 @@ export interface SubAgentOptions {
description: string;
}
export interface OfflineModeOptions {
cloudSubagentName: string;
localModelRouting: string;
}
// --- High Level Composition ---
/**
@@ -121,6 +127,8 @@ ${renderPreamble(options.preamble)}
${renderCoreMandates(options.coreMandates)}
${renderOfflineMode(options.offlineMode)}
${renderSubAgents(options.subAgents)}
${renderAgentSkills(options.agentSkills)}
@@ -216,6 +224,19 @@ For example:
- A test-fixing-agent -> Should be used both for fixing tests as well as investigating test failures.`;
}
export function renderOfflineMode(options?: OfflineModeOptions): string {
if (!options) return '';
return `
# Offline Mode Strategy
- You are operating with **Offline Mode** enabled.
- Handle simple work directly and delegate complex tasks to \`${options.cloudSubagentName}\`.
- Use cloud delegation for high-volume output, speculative investigations, and long-running execution.
- Cloud delegation should use the standard confirmation flow and include audit-friendly context.
- Always include a brief delegation reason so the confirmation request can be audited.
- Current local model routing mode: \`${options.localModelRouting}\` (stubbed to default API backend for now).`;
}
export function renderAgentSkills(skills?: AgentSkillOptions[]): string {
if (!skills || skills.length === 0) return '';
const skillsXml = skills
+22
View File
@@ -43,6 +43,7 @@ import { DEFAULT_CONTEXT_FILENAME } from '../tools/memoryTool.js';
export interface SystemPromptOptions {
preamble?: PreambleOptions;
coreMandates?: CoreMandatesOptions;
offlineMode?: OfflineModeOptions;
subAgents?: SubAgentOptions[];
agentSkills?: AgentSkillOptions[];
hookContext?: boolean;
@@ -115,6 +116,11 @@ export interface SubAgentOptions {
description: string;
}
export interface OfflineModeOptions {
cloudSubagentName: string;
localModelRouting: string;
}
// --- High Level Composition ---
/**
@@ -127,6 +133,8 @@ ${renderPreamble(options.preamble)}
${renderCoreMandates(options.coreMandates)}
${renderOfflineMode(options.offlineMode)}
${renderSubAgents(options.subAgents)}
${renderAgentSkills(options.agentSkills)}
@@ -290,6 +298,20 @@ For example:
- A test-fixing-agent -> Should be used both for fixing tests as well as investigating test failures.`.trim();
}
export function renderOfflineMode(options?: OfflineModeOptions): string {
if (!options) return '';
return `
# Offline Mode Strategy
- You are operating with **Offline Mode** enabled.
- Treat your own thread as local-first: handle surgical or straightforward tasks directly.
- Delegate complex, long-running, high-output, or highly exploratory work to \`${options.cloudSubagentName}\`.
- Cloud delegation should use the standard confirmation flow and include audit-friendly context.
- Every delegation MUST include a concise reason that explains why cloud delegation is justified.
- Keep the main conversation lean by preferring delegation for work that would otherwise bloat context.
- Current local model routing mode: \`${options.localModelRouting}\` (stubbed to default API backend for now).`.trim();
}
export function renderAgentSkills(skills?: AgentSkillOptions[]): string {
if (!skills || skills.length === 0) return '';
const skillsXml = skills
+41
View File
@@ -52,6 +52,25 @@ export interface ModelChangedPayload {
model: string;
}
/**
* Payload for the 'offline-mode-changed' event.
*/
export interface OfflineModeChangedPayload {
/**
* Whether offline mode is currently enabled.
*/
enabled: boolean;
}
/**
* Payload for cloud subagent execution lifecycle events.
*/
export interface CloudSubagentExecutionPayload {
agentName: string;
state: 'started' | 'ended';
outcome?: 'success' | 'error' | 'cancelled';
}
/**
* Payload for the 'console-log' event.
*/
@@ -181,6 +200,8 @@ export interface QuotaChangedPayload {
export enum CoreEvent {
UserFeedback = 'user-feedback',
ModelChanged = 'model-changed',
OfflineModeChanged = 'offline-mode-changed',
CloudSubagentExecution = 'cloud-subagent-execution',
ConsoleLog = 'console-log',
Output = 'output',
MemoryChanged = 'memory-changed',
@@ -215,6 +236,8 @@ export interface EditorSelectedPayload {
export interface CoreEvents extends ExtensionEvents {
[CoreEvent.UserFeedback]: [UserFeedbackPayload];
[CoreEvent.ModelChanged]: [ModelChangedPayload];
[CoreEvent.OfflineModeChanged]: [OfflineModeChangedPayload];
[CoreEvent.CloudSubagentExecution]: [CloudSubagentExecutionPayload];
[CoreEvent.ConsoleLog]: [ConsoleLogPayload];
[CoreEvent.Output]: [OutputPayload];
[CoreEvent.MemoryChanged]: [MemoryChangedPayload];
@@ -327,6 +350,24 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
this.emit(CoreEvent.ModelChanged, payload);
}
emitOfflineModeChanged(enabled: boolean): void {
const payload: OfflineModeChangedPayload = { enabled };
this.emit(CoreEvent.OfflineModeChanged, payload);
}
emitCloudSubagentExecution(
agentName: string,
state: 'started' | 'ended',
outcome?: 'success' | 'error' | 'cancelled',
): void {
const payload: CloudSubagentExecutionPayload = {
agentName,
state,
outcome,
};
this.emit(CoreEvent.CloudSubagentExecution, payload);
}
/**
* Notifies subscribers that settings have been modified.
*/