feat: add offline/hybrid mode with cloud subagent delegation

This commit is contained in:
Samee Zahid
2026-04-15 00:04:48 -07:00
parent fd481ffc25
commit 27f35c3358
27 changed files with 927 additions and 23 deletions
+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,
+10
View File
@@ -433,6 +433,9 @@ export const AppContainer = (props: AppContainerProps) => {
);
const [currentModel, setCurrentModel] = useState(config.getModel());
const [isOfflineMode, setIsOfflineMode] = useState(
config.isOfflineModeEnabled(),
);
const [userTier, setUserTier] = useState<UserTierId | undefined>(undefined);
const [quotaStats, setQuotaStats] = useState<QuotaStats | undefined>(() => {
@@ -567,6 +570,9 @@ export const AppContainer = (props: AppContainerProps) => {
const handleModelChanged = () => {
setCurrentModel(config.getModel());
};
const handleOfflineModeChanged = (payload: { enabled: boolean }) => {
setIsOfflineMode(payload.enabled);
};
const handleQuotaChanged = (payload: {
remaining: number | undefined;
@@ -581,9 +587,11 @@ export const AppContainer = (props: AppContainerProps) => {
};
coreEvents.on(CoreEvent.ModelChanged, handleModelChanged);
coreEvents.on(CoreEvent.OfflineModeChanged, handleOfflineModeChanged);
coreEvents.on(CoreEvent.QuotaChanged, handleQuotaChanged);
return () => {
coreEvents.off(CoreEvent.ModelChanged, handleModelChanged);
coreEvents.off(CoreEvent.OfflineModeChanged, handleOfflineModeChanged);
coreEvents.off(CoreEvent.QuotaChanged, handleQuotaChanged);
};
}, [config]);
@@ -2493,6 +2501,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
queueErrorMessage,
showApprovalModeIndicator,
allowPlanMode,
isOfflineMode,
currentModel,
contextFileNames,
errorCount,
@@ -2604,6 +2613,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
queueErrorMessage,
showApprovalModeIndicator,
allowPlanMode,
isOfflineMode,
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),
}),
};
@@ -34,6 +34,7 @@ describe('<StatusRow />', () => {
contextFileNames: [],
showApprovalModeIndicator: ApprovalMode.DEFAULT,
allowPlanMode: false,
isOfflineMode: false,
renderMarkdown: true,
currentModel: 'gemini-3',
};
@@ -140,4 +141,38 @@ 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,
});
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');
});
});
@@ -411,6 +411,11 @@ export const StatusRow: React.FC<StatusRowProps> = ({
<RawMarkdownIndicator />
</Box>
)}
{uiState.isOfflineMode && (
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
<Text color={theme.status.success}> offline</Text>
</Box>
)}
</>
) : (
showRow2Minimal &&
@@ -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:
'Delegating to cloud-subagent for cloud execution.\nReason: Complex task.\nTask: Analyze migration risks.',
};
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,44 @@ export const ToolConfirmationMessage: React.FC<
key: 'No, suggest changes (esc)',
});
} else if (confirmationDetails.type === 'info') {
const isCloudSubagentConfirmation =
toolName === 'cloud-subagent' || toolName === '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 +448,7 @@ export const ToolConfirmationMessage: React.FC<
allowPermanentApproval,
config,
isDiffingEnabled,
toolName,
]);
const availableBodyContentHeight = useCallback(() => {
@@ -157,6 +157,7 @@ export interface UIState {
queueErrorMessage: string | null;
showApprovalModeIndicator: ApprovalMode;
allowPlanMode: boolean;
isOfflineMode?: boolean;
currentModel: string;
contextFileNames: string[];
errorCount: number;
+29 -10
View File
@@ -21,6 +21,7 @@ export const useComposerStatus = () => {
const uiState = useUIState();
const quotaState = useQuotaState();
const settings = useSettings();
const isOfflineMode = Boolean(uiState.isOfflineMode);
const hasPendingToolConfirmation = useMemo(
() =>
@@ -64,22 +65,40 @@ 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) {
return isOfflineMode
? {
text: `${approvalModeIndicator.text} + offline`,
color: approvalModeIndicator.color,
}
: approvalModeIndicator;
}
if (isOfflineMode) {
return { text: 'offline', color: theme.status.success };
}
return null;
}, [
uiState.cleanUiDetailsVisible,
showLoadingIndicator,
uiState.activeHooks.length,
showApprovalModeIndicator,
isOfflineMode,
]);
const showMinimalContext = isContextUsageHigh(
+59 -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,26 @@ 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: {
task: { type: 'string' },
reason: { type: 'string' },
},
required: ['task', 'reason'],
},
},
modelConfig: { model: 'test', generateContentConfig: {} },
runConfig: { maxTimeMinutes: 1 },
promptConfig: { systemPrompt: 'test' },
};
beforeEach(() => {
vi.clearAllMocks();
mockConfig = makeFakeConfig();
@@ -67,6 +91,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 +166,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();
});
});
+61
View File
@@ -29,6 +29,33 @@ 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';
const CLOUD_DELEGATION_REASON_MAX_LENGTH = 120;
const CLOUD_DELEGATION_TASK_MAX_LENGTH = 140;
const CLOUD_DELEGATION_REASON_FALLBACK =
'Complex work is better handled by the cloud subagent.';
const CLOUD_DELEGATION_TASK_FALLBACK = 'No task summary provided.';
function summarizeInputText(
value: unknown,
maxLength: number,
): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.replace(/\s+/g, ' ').trim();
if (!normalized) {
return undefined;
}
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, maxLength - 3)}...`;
}
/**
* A unified tool for invoking subagents.
@@ -144,6 +171,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,11 +210,42 @@ 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 reason =
summarizeInputText(
this.mappedInputs['reason'],
CLOUD_DELEGATION_REASON_MAX_LENGTH,
) ?? CLOUD_DELEGATION_REASON_FALLBACK;
const task =
summarizeInputText(
this.mappedInputs['task'],
CLOUD_DELEGATION_TASK_MAX_LENGTH,
) ?? CLOUD_DELEGATION_TASK_FALLBACK;
return {
type: 'info',
title: 'Delegate to cloud-subagent',
prompt: [`Reason: ${reason}`, `Task: ${task}`].join('\n'),
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);
@@ -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,97 @@
/**
* @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: {
task: {
type: 'string',
description: 'The delegated task to execute in the cloud context.',
},
reason: {
type: 'string',
description:
'Why delegation is necessary (complexity, volume, uncertainty, or long-running work).',
},
},
required: ['task', 'reason'],
},
},
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: `You are handling a delegated cloud task from the offline-mode orchestrator.
Delegation reason:
${'${reason}'}
Task:
${'${task}'}`,
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
+17
View File
@@ -52,6 +52,16 @@ 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 the 'console-log' event.
*/
@@ -181,6 +191,7 @@ export interface QuotaChangedPayload {
export enum CoreEvent {
UserFeedback = 'user-feedback',
ModelChanged = 'model-changed',
OfflineModeChanged = 'offline-mode-changed',
ConsoleLog = 'console-log',
Output = 'output',
MemoryChanged = 'memory-changed',
@@ -215,6 +226,7 @@ export interface EditorSelectedPayload {
export interface CoreEvents extends ExtensionEvents {
[CoreEvent.UserFeedback]: [UserFeedbackPayload];
[CoreEvent.ModelChanged]: [ModelChangedPayload];
[CoreEvent.OfflineModeChanged]: [OfflineModeChangedPayload];
[CoreEvent.ConsoleLog]: [ConsoleLogPayload];
[CoreEvent.Output]: [OutputPayload];
[CoreEvent.MemoryChanged]: [MemoryChangedPayload];
@@ -327,6 +339,11 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
this.emit(CoreEvent.ModelChanged, payload);
}
emitOfflineModeChanged(enabled: boolean): void {
const payload: OfflineModeChangedPayload = { enabled };
this.emit(CoreEvent.OfflineModeChanged, payload);
}
/**
* Notifies subscribers that settings have been modified.
*/