mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-15 06:12:50 -07:00
feat: add offline/hybrid mode with cloud subagent delegation
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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}"`),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user