From 27f35c3358961dab9548bb8ff0d8ab4fd5f3071e Mon Sep 17 00:00:00 2001 From: Samee Zahid Date: Wed, 15 Apr 2026 00:04:48 -0700 Subject: [PATCH] feat: add offline/hybrid mode with cloud subagent delegation --- packages/cli/src/config/config.test.ts | 40 +++++++ packages/cli/src/config/config.ts | 1 + .../cli/src/config/settingsSchema.test.ts | 25 ++++ packages/cli/src/config/settingsSchema.ts | 38 +++++++ .../src/services/BuiltinCommandLoader.test.ts | 6 + .../cli/src/services/BuiltinCommandLoader.ts | 2 + packages/cli/src/ui/AppContainer.tsx | 10 ++ .../src/ui/commands/offlineCommand.test.ts | 107 ++++++++++++++++++ .../cli/src/ui/commands/offlineCommand.ts | 99 ++++++++++++++++ .../cli/src/ui/components/StatusRow.test.tsx | 35 ++++++ packages/cli/src/ui/components/StatusRow.tsx | 5 + .../messages/ToolConfirmationMessage.test.tsx | 27 +++++ .../messages/ToolConfirmationMessage.tsx | 32 ++++-- .../cli/src/ui/contexts/UIStateContext.tsx | 1 + .../cli/src/ui/hooks/useComposerStatus.ts | 39 +++++-- packages/core/src/agents/agent-tool.test.ts | 60 +++++++++- packages/core/src/agents/agent-tool.ts | 61 ++++++++++ .../core/src/agents/cloud-subagent.test.ts | 52 +++++++++ packages/core/src/agents/cloud-subagent.ts | 97 ++++++++++++++++ packages/core/src/agents/registry.ts | 10 +- packages/core/src/config/config.test.ts | 43 +++++++ packages/core/src/config/config.ts | 44 +++++++ .../core/src/prompts/promptProvider.test.ts | 38 +++++++ packages/core/src/prompts/promptProvider.ts | 18 ++- packages/core/src/prompts/snippets.legacy.ts | 21 ++++ packages/core/src/prompts/snippets.ts | 22 ++++ packages/core/src/utils/events.ts | 17 +++ 27 files changed, 927 insertions(+), 23 deletions(-) create mode 100644 packages/cli/src/ui/commands/offlineCommand.test.ts create mode 100644 packages/cli/src/ui/commands/offlineCommand.ts create mode 100644 packages/core/src/agents/cloud-subagent.test.ts create mode 100644 packages/core/src/agents/cloud-subagent.ts diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 04df366a98..2c0e339a46 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -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; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 4e7e1db6f2..0fc18fbe1c 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -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), diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 27639fa031..09e3f93662 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -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(); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index fcfd604e3a..f59c2fe0c2 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -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', diff --git a/packages/cli/src/services/BuiltinCommandLoader.test.ts b/packages/cli/src/services/BuiltinCommandLoader.test.ts index f166c161cd..62cbe25a5c 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.test.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.test.ts @@ -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 () => { diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index c1cbd5621e..67d22937ef 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -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, diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index f17ac0d756..c8925ae3b2 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -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(undefined); const [quotaStats, setQuotaStats] = useState(() => { @@ -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, diff --git a/packages/cli/src/ui/commands/offlineCommand.test.ts b/packages/cli/src/ui/commands/offlineCommand.test.ts new file mode 100644 index 0000000000..7cdbb3ffb6 --- /dev/null +++ b/packages/cli/src/ui/commands/offlineCommand.test.ts @@ -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.', + }), + ); + }); +}); diff --git a/packages/cli/src/ui/commands/offlineCommand.ts b/packages/cli/src/ui/commands/offlineCommand.ts new file mode 100644 index 0000000000..2388a105d9 --- /dev/null +++ b/packages/cli/src/ui/commands/offlineCommand.ts @@ -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 { + 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), + }), +}; diff --git a/packages/cli/src/ui/components/StatusRow.test.tsx b/packages/cli/src/ui/components/StatusRow.test.tsx index 5f14254f4b..0383de78ff 100644 --- a/packages/cli/src/ui/components/StatusRow.test.tsx +++ b/packages/cli/src/ui/components/StatusRow.test.tsx @@ -34,6 +34,7 @@ describe('', () => { contextFileNames: [], showApprovalModeIndicator: ApprovalMode.DEFAULT, allowPlanMode: false, + isOfflineMode: false, renderMarkdown: true, currentModel: 'gemini-3', }; @@ -140,4 +141,38 @@ describe('', () => { 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 = { + ...defaultUiState, + isOfflineMode: true, + }; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { + width: 100, + uiState, + }, + ); + + await waitUntilReady(); + expect(lastFrame()).toContain('offline'); + }); }); diff --git a/packages/cli/src/ui/components/StatusRow.tsx b/packages/cli/src/ui/components/StatusRow.tsx index f162481ce5..21f7d06387 100644 --- a/packages/cli/src/ui/components/StatusRow.tsx +++ b/packages/cli/src/ui/components/StatusRow.tsx @@ -411,6 +411,11 @@ export const StatusRow: React.FC = ({ )} + {uiState.isOfflineMode && ( + + ● offline + + )} ) : ( showRow2Minimal && diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx index 3a3a4df557..477885b07f 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx @@ -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( + , + ); + + 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', diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index b23282959e..ff4015fd7e 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -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(() => { diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index ed33c21ee5..ce2f3fb7b9 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -157,6 +157,7 @@ export interface UIState { queueErrorMessage: string | null; showApprovalModeIndicator: ApprovalMode; allowPlanMode: boolean; + isOfflineMode?: boolean; currentModel: string; contextFileNames: string[]; errorCount: number; diff --git a/packages/cli/src/ui/hooks/useComposerStatus.ts b/packages/cli/src/ui/hooks/useComposerStatus.ts index 90694f4fd7..fc6614d4f2 100644 --- a/packages/cli/src/ui/hooks/useComposerStatus.ts +++ b/packages/cli/src/ui/hooks/useComposerStatus.ts @@ -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( diff --git a/packages/core/src/agents/agent-tool.test.ts b/packages/core/src/agents/agent-tool.test.ts index 424f1c6bd9..c63ec224ce 100644 --- a/packages/core/src/agents/agent-tool.test.ts +++ b/packages/core/src/agents/agent-tool.test.ts @@ -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(); + }); }); diff --git a/packages/core/src/agents/agent-tool.ts b/packages/core/src/agents/agent-tool.ts index d24636915c..4deb17bc04 100644 --- a/packages/core/src/agents/agent-tool.ts +++ b/packages/core/src/agents/agent-tool.ts @@ -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 { + 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 { + 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 { const { abortSignal: signal, updateOutput } = options; const hintedParams = this.withUserHints(this.mappedInputs); diff --git a/packages/core/src/agents/cloud-subagent.test.ts b/packages/core/src/agents/cloud-subagent.test.ts new file mode 100644 index 0000000000..f17ce30a32 --- /dev/null +++ b/packages/core/src/agents/cloud-subagent.test.ts @@ -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']); + }); +}); diff --git a/packages/core/src/agents/cloud-subagent.ts b/packages/core/src/agents/cloud-subagent.ts new file mode 100644 index 0000000000..f146ec61e1 --- /dev/null +++ b/packages/core/src/agents/cloud-subagent.ts @@ -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 => ({ + 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, + }, +}); diff --git a/packages/core/src/agents/registry.ts b/packages/core/src/agents/registry.ts index ebb757487c..e0bc93768e 100644 --- a/packages/core/src/agents/registry.ts +++ b/packages/core/src/agents/registry.ts @@ -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}"`), diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 3bc1e94f8d..12ea7658de 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -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', diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index a9c0b813ee..49ecf241dc 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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 { + 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 */ diff --git a/packages/core/src/prompts/promptProvider.test.ts b/packages/core/src/prompts/promptProvider.test.ts index 27a0e7844d..6cbf5a2bf5 100644 --- a/packages/core/src/prompts/promptProvider.test.ts +++ b/packages/core/src/prompts/promptProvider.test.ts @@ -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(), diff --git a/packages/core/src/prompts/promptProvider.ts b/packages/core/src/prompts/promptProvider.ts index 36d08c7e74..6ca5b0ac54 100644 --- a/packages/core/src/prompts/promptProvider.ts +++ b/packages/core/src/prompts/promptProvider.ts @@ -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', () => diff --git a/packages/core/src/prompts/snippets.legacy.ts b/packages/core/src/prompts/snippets.legacy.ts index 5f9552b96b..5deef33ab0 100644 --- a/packages/core/src/prompts/snippets.legacy.ts +++ b/packages/core/src/prompts/snippets.legacy.ts @@ -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 diff --git a/packages/core/src/prompts/snippets.ts b/packages/core/src/prompts/snippets.ts index c420f22ae3..eaed5e2d67 100644 --- a/packages/core/src/prompts/snippets.ts +++ b/packages/core/src/prompts/snippets.ts @@ -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 diff --git a/packages/core/src/utils/events.ts b/packages/core/src/utils/events.ts index 9548146f9d..a18a1147c4 100644 --- a/packages/core/src/utils/events.ts +++ b/packages/core/src/utils/events.ts @@ -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 { 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. */