diff --git a/packages/cli/src/acp/acpClient.ts b/packages/cli/src/acp/acpClient.ts index 072d91c20a..357ef3b567 100644 --- a/packages/cli/src/acp/acpClient.ts +++ b/packages/cli/src/acp/acpClient.ts @@ -360,7 +360,10 @@ export class GeminiAgent { config.setFileSystemService(acpFileSystemService); } - const clientHistory = convertSessionToClientHistory(sessionData.messages); + const clientHistory = convertSessionToClientHistory( + sessionData.messages, + sessionData.lastCompressionIndex, + ); const geminiClient = config.getGeminiClient(); await geminiClient.initialize(); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 957bb6510e..1e1b090d95 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -97,6 +97,7 @@ export interface CliArgs { rawOutput: boolean | undefined; acceptRawOutputRisk: boolean | undefined; isCommand: boolean | undefined; + forever: boolean | undefined; } /** @@ -297,6 +298,12 @@ export async function parseArguments( .option('accept-raw-output-risk', { type: 'boolean', description: 'Suppress the security warning when using --raw-output.', + }) + .option('forever', { + type: 'boolean', + description: + 'Run as a long-running autonomous agent with auto-resume and schedule_work support.', + default: false, }), ) // Register MCP subcommands @@ -868,6 +875,14 @@ export async function loadCliConfig( }; }, enableConseca: settings.security?.enableConseca, + isForeverMode: !!argv.forever, + sisyphusMode: argv.forever + ? { + enabled: true, + idleTimeout: settings.hooksConfig?.idleTimeout, + prompt: 'continue workflow', + } + : undefined, }); } diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index b06df48bc3..2e1635ee82 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -2140,6 +2140,16 @@ const SETTINGS_SCHEMA = { description: 'Show visual indicators when hooks are executing.', showInDialog: true, }, + idleTimeout: { + type: 'number', + label: 'Idle Timeout', + category: 'Advanced', + requiresRestart: false, + default: 0, + description: + 'Time in seconds before the Idle hook fires when there is no user input. Set to 0 to disable.', + showInDialog: true, + }, }, }, @@ -2284,6 +2294,18 @@ const SETTINGS_SCHEMA = { ref: 'HookDefinitionArray', mergeStrategy: MergeStrategy.CONCAT, }, + Idle: { + type: 'array', + label: 'Idle Hooks', + category: 'Advanced', + requiresRestart: false, + default: [], + description: + 'Hooks that execute after a period of inactivity. Can trigger maintenance tasks like memory consolidation.', + showInDialog: false, + ref: 'HookDefinitionArray', + mergeStrategy: MergeStrategy.CONCAT, + }, }, additionalProperties: { type: 'array', diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 31fec36db0..ccf58f6f42 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -513,6 +513,7 @@ describe('gemini.tsx main function kitty protocol', () => { rawOutput: undefined, acceptRawOutputRisk: undefined, isCommand: undefined, + forever: undefined, }); await act(async () => { diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 891e3d0ee9..43fb8a3021 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -222,6 +222,7 @@ export async function runNonInteractive({ await geminiClient.resumeChat( convertSessionToClientHistory( resumedSessionData.conversation.messages, + resumedSessionData.conversation.lastCompressionIndex, ), resumedSessionData, ); diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index d96bfe3071..d8e468b18f 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -81,6 +81,7 @@ describe('App', () => { updateItem: vi.fn(), clearItems: vi.fn(), loadHistory: vi.fn(), + pruneItems: vi.fn(), }, history: [], pendingHistoryItems: [], diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index b0a936a81b..d39b0f4f66 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1142,6 +1142,7 @@ Logging in with Google... Restarting Gemini CLI to continue. terminalHeight, embeddedShellFocused, consumePendingHints, + historyManager.pruneItems, ); toggleBackgroundShellRef.current = toggleBackgroundShell; diff --git a/packages/cli/src/ui/hooks/creditsFlowHandler.test.ts b/packages/cli/src/ui/hooks/creditsFlowHandler.test.ts index 37a6294010..60effa0c4a 100644 --- a/packages/cli/src/ui/hooks/creditsFlowHandler.test.ts +++ b/packages/cli/src/ui/hooks/creditsFlowHandler.test.ts @@ -57,6 +57,7 @@ describe('handleCreditsFlow', () => { updateItem: vi.fn(), clearItems: vi.fn(), loadHistory: vi.fn(), + pruneItems: vi.fn(), }; isDialogPending = { current: false }; mockSetOverageMenuRequest = vi.fn(); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index c394b866ad..532912e7ea 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -38,6 +38,7 @@ import { GeminiCliOperation, getPlanModeExitMessage, isBackgroundExecutionData, + SCHEDULE_WORK_TOOL_NAME, } from '@google/gemini-cli-core'; import type { Config, @@ -219,6 +220,7 @@ export const useGeminiStream = ( terminalHeight: number, isShellFocused?: boolean, consumeUserHint?: () => string | null, + pruneItems?: () => void, ) => { const [initError, setInitError] = useState(null); const [retryStatus, setRetryStatus] = useState( @@ -255,6 +257,16 @@ export const useGeminiStream = ( const [_isFirstToolInGroup, isFirstToolInGroupRef, setIsFirstToolInGroup] = useStateAndRef(true); const processedMemoryToolsRef = useRef>(new Set()); + + // Sisyphus Mode: schedule_work auto-resume timer + const sisyphusTargetTimestampRef = useRef(null); + const [sisyphusSecondsRemaining, setSisyphusSecondsRemaining] = useState< + number | null + >(null); + const submitQueryRef = useRef<(query: PartListUnion) => Promise>(() => + Promise.resolve(), + ); + const { startNewPrompt, getPromptCount } = useSessionStats(); const storage = config.storage; const logger = useLogger(storage); @@ -1127,8 +1139,12 @@ export const useGeminiStream = ( } as HistoryItemInfo, userMessageTimestamp, ); + + // Prune old UI history items to prevent unbounded memory growth + // in long-running sessions. + pruneItems?.(); }, - [addItem, pendingHistoryItemRef, setPendingHistoryItem, config], + [addItem, pendingHistoryItemRef, setPendingHistoryItem, config, pruneItems], ); const handleMaxSessionTurnsEvent = useCallback( @@ -1292,6 +1308,15 @@ export const useGeminiStream = ( ); break; case ServerGeminiEventType.ToolCallRequest: + if (event.value.name === SCHEDULE_WORK_TOOL_NAME) { + const args = event.value.args; + const inMinutes = Number(args?.['inMinutes'] ?? 0); + if (inMinutes > 0) { + const delayMs = inMinutes * 60 * 1000; + sisyphusTargetTimestampRef.current = Date.now() + delayMs; + setSisyphusSecondsRemaining(Math.ceil(delayMs / 1000)); + } + } toolCallRequests.push(event.value); break; case ServerGeminiEventType.UserCancelled: @@ -1910,6 +1935,104 @@ export const useGeminiStream = ( storage, ]); + // Idle hook timer: fires after idleTimeout seconds of no activity. + // If idleTimeout is explicitly set, use it. Otherwise, if any Idle hooks + // are registered (e.g. by an extension), use a default of 300 seconds. + const DEFAULT_IDLE_TIMEOUT = 300; + const idleTimerRef = useRef | null>(null); + const configuredIdleTimeout = settings.merged.hooksConfig?.idleTimeout ?? 0; + useEffect(() => { + // Clear any existing timer + if (idleTimerRef.current) { + clearTimeout(idleTimerRef.current); + idleTimerRef.current = null; + } + + if (streamingState !== StreamingState.Idle || !config.getEnableHooks()) { + return; + } + + // Compute effective timeout: use configured value, or default if + // Idle hooks are registered (e.g. by an extension). + let idleTimeoutSeconds: number = configuredIdleTimeout; + if (idleTimeoutSeconds <= 0) { + const hookSystem = config.getHookSystem(); + const hasIdleHooks = hookSystem + ?.getAllHooks() + .some((h) => h.eventName === 'Idle' && h.enabled); + idleTimeoutSeconds = hasIdleHooks ? DEFAULT_IDLE_TIMEOUT : 0; + } + + if (idleTimeoutSeconds <= 0) { + return; + } + + const startTime = Date.now(); + idleTimerRef.current = setTimeout(async () => { + const hookSystem = config.getHookSystem(); + if (!hookSystem) return; + + const elapsed = Math.round((Date.now() - startTime) / 1000); + try { + const result = await hookSystem.fireIdleEvent(elapsed); + const prompt = result?.finalOutput?.hookSpecificOutput?.['prompt']; + if (typeof prompt === 'string' && prompt.trim()) { + // Auto-submit the prompt returned by the hook + void submitQuery(prompt); + } + } catch { + // Idle hook failures are non-fatal + } + }, idleTimeoutSeconds * 1000); + + return () => { + if (idleTimerRef.current) { + clearTimeout(idleTimerRef.current); + idleTimerRef.current = null; + } + }; + }, [streamingState, configuredIdleTimeout, config, submitQuery]); + + // Keep submitQueryRef in sync for Sisyphus timer + useEffect(() => { + submitQueryRef.current = submitQuery; + }, [submitQuery]); + + // Sisyphus: auto-resume when countdown reaches zero + useEffect(() => { + if ( + streamingState === StreamingState.Idle && + sisyphusSecondsRemaining !== null && + sisyphusSecondsRemaining <= 0 + ) { + sisyphusTargetTimestampRef.current = null; + setSisyphusSecondsRemaining(null); + void submitQueryRef.current( + 'System: The scheduled break has ended. Please resume your work.', + ); + } + }, [streamingState, sisyphusSecondsRemaining]); + + // Sisyphus: countdown timer interval + useEffect(() => { + if ( + sisyphusTargetTimestampRef.current === null || + streamingState !== StreamingState.Idle + ) { + return; + } + + const timer = setInterval(() => { + if (sisyphusTargetTimestampRef.current !== null) { + const remainingMs = sisyphusTargetTimestampRef.current - Date.now(); + const remainingSecs = Math.max(0, Math.ceil(remainingMs / 1000)); + setSisyphusSecondsRemaining(remainingSecs); + } + }, 1000); + + return () => clearInterval(timer); + }, [streamingState]); + const lastOutputTime = Math.max( lastToolOutputTime, lastShellOutputTime, @@ -1935,5 +2058,6 @@ export const useGeminiStream = ( backgroundShells, dismissBackgroundShell, retryStatus, + sisyphusSecondsRemaining, }; }; diff --git a/packages/cli/src/ui/hooks/useHistoryManager.test.ts b/packages/cli/src/ui/hooks/useHistoryManager.test.ts index 696f9d60c0..37e134f308 100644 --- a/packages/cli/src/ui/hooks/useHistoryManager.test.ts +++ b/packages/cli/src/ui/hooks/useHistoryManager.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect } from 'vitest'; import { act } from 'react'; import { renderHook } from '../../test-utils/render.js'; -import { useHistory } from './useHistoryManager.js'; +import { useHistory, PRUNE_KEEP_COUNT } from './useHistoryManager.js'; import type { HistoryItem } from '../types.js'; describe('useHistoryManager', () => { @@ -255,4 +255,82 @@ describe('useHistoryManager', () => { expect(result.current.history[0].type).toBe('info'); }); }); + + describe('pruneItems', () => { + it('should prune history to PRUNE_KEEP_COUNT + 1 (marker) when over limit', () => { + const { result } = renderHook(() => useHistory()); + const itemCount = PRUNE_KEEP_COUNT + 20; + + act(() => { + for (let i = 0; i < itemCount; i++) { + result.current.addItem({ + type: 'user', + text: `Message ${i}`, + }); + } + }); + + expect(result.current.history).toHaveLength(itemCount); + + act(() => { + result.current.pruneItems(); + }); + + // PRUNE_KEEP_COUNT items + 1 prune marker + expect(result.current.history).toHaveLength(PRUNE_KEEP_COUNT + 1); + // First item should be the prune marker + expect(result.current.history[0].type).toBe('info'); + expect(result.current.history[0].text).toContain('pruned'); + // Last item should be the most recent message + expect( + result.current.history[result.current.history.length - 1].text, + ).toBe(`Message ${itemCount - 1}`); + }); + + it('should be a no-op when history is under the threshold', () => { + const { result } = renderHook(() => useHistory()); + const itemCount = 10; + + act(() => { + for (let i = 0; i < itemCount; i++) { + result.current.addItem({ + type: 'user', + text: `Message ${i}`, + }); + } + }); + + const historyBefore = result.current.history; + + act(() => { + result.current.pruneItems(); + }); + + // Should be unchanged (same reference) + expect(result.current.history).toBe(historyBefore); + expect(result.current.history).toHaveLength(itemCount); + }); + + it('should be a no-op when history is exactly at the threshold', () => { + const { result } = renderHook(() => useHistory()); + + act(() => { + for (let i = 0; i < PRUNE_KEEP_COUNT; i++) { + result.current.addItem({ + type: 'user', + text: `Message ${i}`, + }); + } + }); + + const historyBefore = result.current.history; + + act(() => { + result.current.pruneItems(); + }); + + expect(result.current.history).toBe(historyBefore); + expect(result.current.history).toHaveLength(PRUNE_KEEP_COUNT); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/useHistoryManager.ts b/packages/cli/src/ui/hooks/useHistoryManager.ts index 93f7f01f28..f41ae4cae2 100644 --- a/packages/cli/src/ui/hooks/useHistoryManager.ts +++ b/packages/cli/src/ui/hooks/useHistoryManager.ts @@ -8,6 +8,12 @@ import { useState, useRef, useCallback, useMemo } from 'react'; import type { HistoryItem } from '../types.js'; import type { ChatRecordingService } from '@google/gemini-cli-core/src/services/chatRecordingService.js'; +/** + * Number of history items to keep when pruning after context compression. + * Exported for testing purposes. + */ +export const PRUNE_KEEP_COUNT = 50; + // Type for the updater function passed to updateHistoryItem type HistoryItemUpdater = ( prevItem: HistoryItem, @@ -26,6 +32,7 @@ export interface UseHistoryManagerReturn { ) => void; clearItems: () => void; loadHistory: (newHistory: HistoryItem[]) => void; + pruneItems: () => void; } /** @@ -156,6 +163,25 @@ export function useHistory({ messageIdCounterRef.current = 0; }, []); + // Prunes old history items, keeping only the most recent PRUNE_KEEP_COUNT. + // Intended to be called after context compression to free memory in + // long-running sessions. + const pruneItems = useCallback(() => { + setHistory((prevHistory) => { + if (prevHistory.length <= PRUNE_KEEP_COUNT) { + return prevHistory; + } + const kept = prevHistory.slice(-PRUNE_KEEP_COUNT); + + const marker = { + id: getNextMessageId(Date.now()), + type: 'info', + text: `ℹ️ Earlier history was pruned after context compression.`, + } as HistoryItem; + return [marker, ...kept]; + }); + }, [getNextMessageId]); + return useMemo( () => ({ history, @@ -163,7 +189,8 @@ export function useHistory({ updateItem, clearItems, loadHistory, + pruneItems, }), - [history, addItem, updateItem, clearItems, loadHistory], + [history, addItem, updateItem, clearItems, loadHistory, pruneItems], ); } diff --git a/packages/cli/src/ui/hooks/useIncludeDirsTrust.test.tsx b/packages/cli/src/ui/hooks/useIncludeDirsTrust.test.tsx index 3f9c656048..5980084fbb 100644 --- a/packages/cli/src/ui/hooks/useIncludeDirsTrust.test.tsx +++ b/packages/cli/src/ui/hooks/useIncludeDirsTrust.test.tsx @@ -91,6 +91,7 @@ describe('useIncludeDirsTrust', () => { updateItem: vi.fn(), clearItems: vi.fn(), loadHistory: vi.fn(), + pruneItems: vi.fn(), }; mockSetCustomDialog = vi.fn(); }); diff --git a/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts b/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts index ea4234bd10..269134adac 100644 --- a/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts +++ b/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts @@ -85,6 +85,7 @@ describe('useQuotaAndFallback', () => { updateItem: vi.fn(), clearItems: vi.fn(), loadHistory: vi.fn(), + pruneItems: vi.fn(), }; mockSetModelSwitchedFromQuotaError = vi.fn(); mockOnShowAuthSelection = vi.fn(); diff --git a/packages/cli/src/ui/hooks/useSessionBrowser.ts b/packages/cli/src/ui/hooks/useSessionBrowser.ts index 9a34f68e0b..ee97003f46 100644 --- a/packages/cli/src/ui/hooks/useSessionBrowser.ts +++ b/packages/cli/src/ui/hooks/useSessionBrowser.ts @@ -78,12 +78,17 @@ export const useSessionBrowser = ( // We've loaded it; tell the UI about it. setIsSessionBrowserOpen(false); + const compressionIndex = conversation.lastCompressionIndex; const historyData = convertSessionToHistoryFormats( conversation.messages, + compressionIndex, ); await onLoadHistory( historyData.uiHistory, - convertSessionToClientHistory(conversation.messages), + convertSessionToClientHistory( + conversation.messages, + compressionIndex, + ), resumedSessionData, ); } catch (error) { diff --git a/packages/cli/src/ui/hooks/useSessionResume.test.ts b/packages/cli/src/ui/hooks/useSessionResume.test.ts index 9350cc167a..b6f098535d 100644 --- a/packages/cli/src/ui/hooks/useSessionResume.test.ts +++ b/packages/cli/src/ui/hooks/useSessionResume.test.ts @@ -33,6 +33,7 @@ describe('useSessionResume', () => { updateItem: vi.fn(), clearItems: vi.fn(), loadHistory: vi.fn(), + pruneItems: vi.fn(), }); let mockHistoryManager: UseHistoryManagerReturn; diff --git a/packages/cli/src/ui/hooks/useSessionResume.ts b/packages/cli/src/ui/hooks/useSessionResume.ts index 055686773b..affa2bcf7f 100644 --- a/packages/cli/src/ui/hooks/useSessionResume.ts +++ b/packages/cli/src/ui/hooks/useSessionResume.ts @@ -109,12 +109,18 @@ export function useSessionResume({ !hasLoadedResumedSession.current ) { hasLoadedResumedSession.current = true; + const compressionIndex = + resumedSessionData.conversation.lastCompressionIndex; const historyData = convertSessionToHistoryFormats( resumedSessionData.conversation.messages, + compressionIndex, ); void loadHistoryForResume( historyData.uiHistory, - convertSessionToClientHistory(resumedSessionData.conversation.messages), + convertSessionToClientHistory( + resumedSessionData.conversation.messages, + compressionIndex, + ), resumedSessionData, ); } diff --git a/packages/cli/src/utils/sessionUtils.ts b/packages/cli/src/utils/sessionUtils.ts index 3aa0131ac2..b341cc2c9d 100644 --- a/packages/cli/src/utils/sessionUtils.ts +++ b/packages/cli/src/utils/sessionUtils.ts @@ -529,12 +529,24 @@ export class SessionSelector { */ export function convertSessionToHistoryFormats( messages: ConversationRecord['messages'], + startIndex?: number, ): { uiHistory: HistoryItemWithoutId[]; } { const uiHistory: HistoryItemWithoutId[] = []; - for (const msg of messages) { + const hasCompressedHistory = + startIndex != null && startIndex > 0 && startIndex < messages.length; + const slice = hasCompressedHistory ? messages.slice(startIndex) : messages; + + if (hasCompressedHistory) { + uiHistory.push({ + type: MessageType.INFO, + text: `ℹ️ Earlier history (${startIndex} messages) was compressed. Showing post-compression messages only.`, + }); + } + + for (const msg of slice) { // Add thoughts if present if (msg.type === 'gemini' && msg.thoughts && msg.thoughts.length > 0) { for (const thought of msg.thoughts) { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 2e9102250c..f3b783954b 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -33,6 +33,7 @@ import { WebFetchTool } from '../tools/web-fetch.js'; import { MemoryTool, setGeminiMdFilename } from '../tools/memoryTool.js'; import { WebSearchTool } from '../tools/web-search.js'; import { AskUserTool } from '../tools/ask-user.js'; +import { ScheduleWorkTool } from '../tools/schedule-work.js'; import { ExitPlanModeTool } from '../tools/exit-plan-mode.js'; import { EnterPlanModeTool } from '../tools/enter-plan-mode.js'; import { GeminiClient } from '../core/client.js'; @@ -249,6 +250,13 @@ export interface AgentSettings { browser?: BrowserAgentCustomConfig; } +export interface SisyphusModeSettings { + enabled: boolean; + idleTimeout?: number; + prompt?: string; + a2aPort?: number; +} + export interface CustomTheme { type: 'custom'; name: string; @@ -637,6 +645,8 @@ export interface ConfigParameters { mcpEnabled?: boolean; extensionsEnabled?: boolean; agents?: AgentSettings; + sisyphusMode?: SisyphusModeSettings; + isForeverMode?: boolean; onReload?: () => Promise<{ disabledSkills?: string[]; adminSkillsEnabled?: boolean; @@ -842,6 +852,8 @@ export class Config implements McpContext, AgentLoopContext { private readonly enableAgents: boolean; private agents: AgentSettings; + private readonly isForeverMode: boolean; + private readonly sisyphusMode: SisyphusModeSettings; private readonly enableEventDrivenScheduler: boolean; private readonly skillsSupport: boolean; private disabledSkills: string[]; @@ -953,6 +965,13 @@ export class Config implements McpContext, AgentLoopContext { this._activeModel = params.model; this.enableAgents = params.enableAgents ?? true; this.agents = params.agents ?? {}; + this.isForeverMode = params.isForeverMode ?? false; + this.sisyphusMode = { + enabled: params.sisyphusMode?.enabled ?? false, + idleTimeout: params.sisyphusMode?.idleTimeout, + prompt: params.sisyphusMode?.prompt, + a2aPort: params.sisyphusMode?.a2aPort, + }; this.disableLLMCorrection = params.disableLLMCorrection ?? true; this.planEnabled = params.plan ?? true; this.trackerEnabled = params.tracker ?? false; @@ -2627,6 +2646,14 @@ export class Config implements McpContext, AgentLoopContext { return remoteThreshold; } + getIsForeverMode(): boolean { + return this.isForeverMode; + } + + getSisyphusMode(): SisyphusModeSettings { + return this.sisyphusMode; + } + async getUserCaching(): Promise { await this.ensureExperimentsLoaded(); @@ -2778,6 +2805,7 @@ export class Config implements McpContext, AgentLoopContext { } isInteractiveShellEnabled(): boolean { + if (this.isForeverMode) return false; return ( this.interactive && this.ptyInfo !== 'child_process' && @@ -3095,15 +3123,22 @@ export class Config implements McpContext, AgentLoopContext { maybeRegister(ShellTool, () => registry.registerTool(new ShellTool(this, this.messageBus)), ); - maybeRegister(MemoryTool, () => - registry.registerTool(new MemoryTool(this.messageBus)), - ); + if (!this.isForeverMode) { + maybeRegister(MemoryTool, () => + registry.registerTool(new MemoryTool(this.messageBus)), + ); + } maybeRegister(WebSearchTool, () => registry.registerTool(new WebSearchTool(this, this.messageBus)), ); maybeRegister(AskUserTool, () => registry.registerTool(new AskUserTool(this.messageBus)), ); + if (this.isForeverMode) { + maybeRegister(ScheduleWorkTool, () => + registry.registerTool(new ScheduleWorkTool(this.messageBus)), + ); + } if (this.getUseWriteTodos()) { maybeRegister(WriteTodosTool, () => registry.registerTool(new WriteTodosTool(this.messageBus)), @@ -3113,9 +3148,11 @@ export class Config implements McpContext, AgentLoopContext { maybeRegister(ExitPlanModeTool, () => registry.registerTool(new ExitPlanModeTool(this, this.messageBus)), ); - maybeRegister(EnterPlanModeTool, () => - registry.registerTool(new EnterPlanModeTool(this, this.messageBus)), - ); + if (!this.isForeverMode) { + maybeRegister(EnterPlanModeTool, () => + registry.registerTool(new EnterPlanModeTool(this, this.messageBus)), + ); + } } if (this.isTrackerEnabled()) { diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 984ab2c199..cc104d5aa7 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -143,6 +143,7 @@ const mockHookSystem = { fireBeforeAgentEvent: vi.fn().mockResolvedValue(undefined), fireAfterAgentEvent: vi.fn().mockResolvedValue(undefined), firePreCompressEvent: vi.fn().mockResolvedValue(undefined), + fireIdleEvent: vi.fn().mockResolvedValue(undefined), }; /** @@ -450,6 +451,7 @@ describe('Gemini Client (client.ts)', () => { getChatRecordingService: vi.fn().mockReturnValue({ getConversation: vi.fn().mockReturnValue(null), getConversationFilePath: vi.fn().mockReturnValue(null), + recordCompressionPoint: vi.fn(), }), }; client['chat'] = mockOriginalChat as GeminiChat; @@ -684,6 +686,7 @@ describe('Gemini Client (client.ts)', () => { const mockRecordingService = { getConversation: vi.fn().mockReturnValue(mockConversation), getConversationFilePath: vi.fn().mockReturnValue(mockFilePath), + recordCompressionPoint: vi.fn(), }; vi.mocked(mockOriginalChat.getChatRecordingService!).mockReturnValue( mockRecordingService as unknown as ChatRecordingService, diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 985670c7da..cfcda451b2 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -1175,6 +1175,7 @@ export class GeminiClient { // capture current session data before resetting const currentRecordingService = this.getChat().getChatRecordingService(); + currentRecordingService.recordCompressionPoint(); const conversation = currentRecordingService.getConversation(); const filePath = currentRecordingService.getConversationFilePath(); diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 9c0e536c48..6b26699dc2 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -182,6 +182,9 @@ export enum CompressionStatus { /** The compression was skipped due to previous failure, but content was truncated to budget */ CONTENT_TRUNCATED, + + /** The compression was replaced by a PreCompress hook */ + HOOK_REPLACED, } export interface ChatCompressionInfo { diff --git a/packages/core/src/hooks/hookEventHandler.ts b/packages/core/src/hooks/hookEventHandler.ts index a092bed334..2130a72b85 100644 --- a/packages/core/src/hooks/hookEventHandler.ts +++ b/packages/core/src/hooks/hookEventHandler.ts @@ -29,6 +29,7 @@ import { type PreCompressTrigger, type HookExecutionResult, type McpToolContext, + type IdleInput, } from './types.js'; import { defaultHookTranslator } from './hookTranslator.js'; import type { @@ -204,16 +205,30 @@ export class HookEventHandler { */ async firePreCompressEvent( trigger: PreCompressTrigger, + history: Array<{ role: string; parts: Array<{ text?: string }> }>, ): Promise { const input: PreCompressInput = { ...this.createBaseInput(HookEventName.PreCompress), trigger, + history, }; const context: HookEventContext = { trigger }; return this.executeHooks(HookEventName.PreCompress, input, context); } + /** + * Fire an Idle event + */ + async fireIdleEvent(idleSeconds: number): Promise { + const input: IdleInput = { + ...this.createBaseInput(HookEventName.Idle), + idle_seconds: idleSeconds, + }; + + return this.executeHooks(HookEventName.Idle, input); + } + /** * Fire a BeforeModel event * Called by handleHookExecutionRequest - executes hooks directly diff --git a/packages/core/src/hooks/hookSystem.ts b/packages/core/src/hooks/hookSystem.ts index f748665985..96f0c7522f 100644 --- a/packages/core/src/hooks/hookSystem.ts +++ b/packages/core/src/hooks/hookSystem.ts @@ -232,8 +232,15 @@ export class HookSystem { async firePreCompressEvent( trigger: PreCompressTrigger, + history: Array<{ role: string; parts: Array<{ text?: string }> }>, ): Promise { - return this.hookEventHandler.firePreCompressEvent(trigger); + return this.hookEventHandler.firePreCompressEvent(trigger, history); + } + + async fireIdleEvent( + idleSeconds: number, + ): Promise { + return this.hookEventHandler.fireIdleEvent(idleSeconds); } async fireBeforeAgentEvent( diff --git a/packages/core/src/hooks/types.test.ts b/packages/core/src/hooks/types.test.ts index ab809cbec7..a0c4d4d81c 100644 --- a/packages/core/src/hooks/types.test.ts +++ b/packages/core/src/hooks/types.test.ts @@ -57,6 +57,7 @@ describe('Hook Types', () => { 'BeforeModel', 'AfterModel', 'BeforeToolSelection', + 'Idle', ]; for (const event of expectedEvents) { diff --git a/packages/core/src/hooks/types.ts b/packages/core/src/hooks/types.ts index 9c6217ffa4..c95c3c388e 100644 --- a/packages/core/src/hooks/types.ts +++ b/packages/core/src/hooks/types.ts @@ -43,12 +43,18 @@ export enum HookEventName { BeforeModel = 'BeforeModel', AfterModel = 'AfterModel', BeforeToolSelection = 'BeforeToolSelection', + Idle = 'Idle', } /** * Fields in the hooks configuration that are not hook event names */ -export const HOOKS_CONFIG_FIELDS = ['enabled', 'disabled', 'notifications']; +export const HOOKS_CONFIG_FIELDS = [ + 'enabled', + 'disabled', + 'notifications', + 'idleTimeout', +]; /** * Hook implementation types @@ -642,14 +648,37 @@ export enum PreCompressTrigger { */ export interface PreCompressInput extends HookInput { trigger: PreCompressTrigger; + history: Array<{ role: string; parts: Array<{ text?: string }> }>; } - /** * PreCompress hook output */ export interface PreCompressOutput { suppressOutput?: boolean; systemMessage?: string; + hookSpecificOutput?: { + hookEventName: 'PreCompress'; + newHistory?: Array<{ role: string; parts: Array<{ text?: string }> }>; + }; +} + +/** + * Idle hook input + */ +export interface IdleInput extends HookInput { + idle_seconds: number; +} + +/** + * Idle hook output + */ +export interface IdleOutput { + suppressOutput?: boolean; + systemMessage?: string; + hookSpecificOutput?: { + hookEventName: 'Idle'; + prompt?: string; + }; } /** diff --git a/packages/core/src/services/chatCompressionService.test.ts b/packages/core/src/services/chatCompressionService.test.ts index c4f26dedc0..9b40410c38 100644 --- a/packages/core/src/services/chatCompressionService.test.ts +++ b/packages/core/src/services/chatCompressionService.test.ts @@ -186,7 +186,7 @@ describe('ChatCompressionService', () => { }), getEnableHooks: vi.fn().mockReturnValue(false), getMessageBus: vi.fn().mockReturnValue(undefined), - getHookSystem: () => undefined, + getHookSystem: vi.fn().mockReturnValue(undefined), getNextCompressionTruncationId: vi.fn().mockReturnValue(1), getTruncateToolOutputThreshold: vi.fn().mockReturnValue(40000), storage: { @@ -897,4 +897,151 @@ describe('ChatCompressionService', () => { ); }); }); + + describe('PreCompress hook replacement', () => { + it('should use hook-provided newHistory and skip built-in compression', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'msg1' }] }, + { role: 'model', parts: [{ text: 'msg2' }] }, + { role: 'user', parts: [{ text: 'msg3' }] }, + { role: 'model', parts: [{ text: 'msg4' }] }, + ]; + vi.mocked(mockChat.getHistory).mockReturnValue(history); + vi.mocked(mockChat.getLastPromptTokenCount).mockReturnValue(600000); + vi.mocked(tokenLimit).mockReturnValue(1_000_000); + + const hookReplacementHistory = [ + { + role: 'user', + parts: [{ text: 'Archive summary: topics discussed...' }], + }, + { + role: 'model', + parts: [{ text: 'Understood, continuing from archive.' }], + }, + ]; + + const mockHookSystem = { + firePreCompressEvent: vi.fn().mockResolvedValue({ + success: true, + finalOutput: { + hookSpecificOutput: { + hookEventName: 'PreCompress', + newHistory: hookReplacementHistory, + }, + }, + allOutputs: [], + errors: [], + totalDuration: 100, + }), + }; + + vi.mocked(mockConfig.getHookSystem).mockReturnValue( + mockHookSystem as unknown as ReturnType, + ); + + const result = await service.compress( + mockChat, + mockPromptId, + true, + mockModel, + mockConfig, + false, + ); + + expect(result.info.compressionStatus).toBe( + CompressionStatus.HOOK_REPLACED, + ); + expect(result.newHistory).not.toBeNull(); + expect(result.newHistory!.length).toBe(2); + expect(result.newHistory![0].parts![0].text).toBe( + 'Archive summary: topics discussed...', + ); + // Built-in LLM compression should NOT have been called + expect( + mockConfig.getBaseLlmClient().generateContent, + ).not.toHaveBeenCalled(); + }); + + it('should proceed with built-in compression when hook returns no newHistory', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'msg1' }] }, + { role: 'model', parts: [{ text: 'msg2' }] }, + { role: 'user', parts: [{ text: 'msg3' }] }, + { role: 'model', parts: [{ text: 'msg4' }] }, + ]; + vi.mocked(mockChat.getHistory).mockReturnValue(history); + vi.mocked(mockChat.getLastPromptTokenCount).mockReturnValue(600000); + vi.mocked(tokenLimit).mockReturnValue(1_000_000); + + const mockHookSystem = { + firePreCompressEvent: vi.fn().mockResolvedValue({ + success: true, + finalOutput: { + systemMessage: 'Compression starting...', + }, + allOutputs: [], + errors: [], + totalDuration: 50, + }), + }; + + vi.mocked(mockConfig.getHookSystem).mockReturnValue( + mockHookSystem as unknown as ReturnType, + ); + + const result = await service.compress( + mockChat, + mockPromptId, + true, + mockModel, + mockConfig, + false, + ); + + // Should fall through to normal compression + expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED); + expect(mockConfig.getBaseLlmClient().generateContent).toHaveBeenCalled(); + }); + + it('should pass history to the hook', async () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'hello' }] }, + { role: 'model', parts: [{ text: 'world' }] }, + ]; + vi.mocked(mockChat.getHistory).mockReturnValue(history); + vi.mocked(mockChat.getLastPromptTokenCount).mockReturnValue(600000); + vi.mocked(tokenLimit).mockReturnValue(1_000_000); + + const mockHookSystem = { + firePreCompressEvent: vi.fn().mockResolvedValue({ + success: true, + allOutputs: [], + errors: [], + totalDuration: 10, + }), + }; + + vi.mocked(mockConfig.getHookSystem).mockReturnValue( + mockHookSystem as unknown as ReturnType, + ); + + await service.compress( + mockChat, + mockPromptId, + true, + mockModel, + mockConfig, + false, + ); + + expect(mockHookSystem.firePreCompressEvent).toHaveBeenCalledWith( + 'manual', + [ + { role: 'user', parts: [{ text: 'hello' }] }, + { role: 'model', parts: [{ text: 'world' }] }, + ], + ); + }); + }); }); diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index a1f9c12f2c..c755326e81 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -254,11 +254,6 @@ export class ChatCompressionService { }; } - // Fire PreCompress hook before compression - // This fires for both manual and auto compression attempts - const trigger = force ? PreCompressTrigger.Manual : PreCompressTrigger.Auto; - await config.getHookSystem()?.firePreCompressEvent(trigger); - const originalTokenCount = chat.getLastPromptTokenCount(); // Don't compress if not forced and we are under the limit. @@ -278,6 +273,63 @@ export class ChatCompressionService { } } + // Fire PreCompress hook — only when compression will actually proceed + const trigger = force ? PreCompressTrigger.Manual : PreCompressTrigger.Auto; + + // Serialize history for the hook: strip non-text parts to keep payload manageable + const curatedForHook = curatedHistory.map((c) => ({ + role: c.role ?? 'user', + parts: (c.parts ?? []) + .filter((p): p is { text: string } => typeof p.text === 'string') + .map((p) => ({ text: p.text })), + })); + + const hookResult = await config + .getHookSystem() + ?.firePreCompressEvent(trigger, curatedForHook); + + // If a hook provided replacement history, use it and skip built-in compression + const hookNewHistory = + hookResult?.finalOutput?.hookSpecificOutput?.['newHistory']; + if (Array.isArray(hookNewHistory) && hookNewHistory.length > 0) { + // Convert hook output back to Content[] + const replacementHistory: Content[] = hookNewHistory.map( + (entry: { role?: string; parts?: Array<{ text?: string }> }) => { + const role = + entry.role === 'model' || entry.role === 'user' + ? entry.role + : 'user'; + return { + role, + parts: (entry.parts ?? []).map((p: { text?: string }) => ({ + text: p.text ?? '', + })), + }; + }, + ); + + const newTokenCount = estimateTokenCountSync( + replacementHistory.flatMap((c) => c.parts || []), + ); + + logChatCompression( + config, + makeChatCompressionEvent({ + tokens_before: originalTokenCount, + tokens_after: newTokenCount, + }), + ); + + return { + newHistory: replacementHistory, + info: { + originalTokenCount, + newTokenCount, + compressionStatus: CompressionStatus.HOOK_REPLACED, + }, + }; + } + // Apply token-based truncation to the entire history before splitting. // This ensures that even the "to compress" portion is within safe limits for the summarization model. const truncatedHistory = await truncateHistoryToBudget( diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 2591d90bb4..b6a0aab9bd 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -104,6 +104,8 @@ export interface ConversationRecord { directories?: string[]; /** The kind of conversation (main agent or subagent) */ kind?: 'main' | 'subagent'; + /** Index into messages[] after the last compression, used to skip pre-compressed messages on resume */ + lastCompressionIndex?: number; } /** @@ -727,6 +729,17 @@ export class ChatRecordingService { } } + /** + * Stamps the current end of the messages array so that future session + * resumes can skip the pre-compression portion of the history. + */ + recordCompressionPoint(): void { + if (!this.conversationFile) return; + this.updateConversation((conversation) => { + conversation.lastCompressionIndex = conversation.messages.length; + }); + } + /** * Rewinds the conversation to the state just before the specified message ID. * All messages from (and including) the specified ID onwards are removed. @@ -759,37 +772,39 @@ export class ChatRecordingService { updateMessagesFromHistory(history: readonly Content[]): void { if (!this.conversationFile) return; + // Build the partsMap before touching the file — skip I/O entirely when + // there are no tool results to sync. + const partsMap = new Map(); + for (const content of history) { + if (content.role === 'user' && content.parts) { + // Find all unique call IDs in this message + const callIds = content.parts + .map((p) => p.functionResponse?.id) + .filter((id): id is string => !!id); + + if (callIds.length === 0) continue; + + // Use the first ID as a seed to capture any "leading" non-ID parts + // in this specific content block. + let currentCallId = callIds[0]; + for (const part of content.parts) { + if (part.functionResponse?.id) { + currentCallId = part.functionResponse.id; + } + + if (!partsMap.has(currentCallId)) { + partsMap.set(currentCallId, []); + } + partsMap.get(currentCallId)!.push(part); + } + } + } + + // No tool results to update — skip file I/O entirely. + if (partsMap.size === 0) return; + try { this.updateConversation((conversation) => { - // Create a map of tool results from the API history for quick lookup by call ID. - // We store the full list of parts associated with each tool call ID to preserve - // multi-modal data and proper trajectory structure. - const partsMap = new Map(); - for (const content of history) { - if (content.role === 'user' && content.parts) { - // Find all unique call IDs in this message - const callIds = content.parts - .map((p) => p.functionResponse?.id) - .filter((id): id is string => !!id); - - if (callIds.length === 0) continue; - - // Use the first ID as a seed to capture any "leading" non-ID parts - // in this specific content block. - let currentCallId = callIds[0]; - for (const part of content.parts) { - if (part.functionResponse?.id) { - currentCallId = part.functionResponse.id; - } - - if (!partsMap.has(currentCallId)) { - partsMap.set(currentCallId, []); - } - partsMap.get(currentCallId)!.push(part); - } - } - } - // Update the conversation records tool results if they've changed. for (const message of conversation.messages) { if (message.type === 'gemini' && message.toolCalls) { diff --git a/packages/core/src/tools/schedule-work.ts b/packages/core/src/tools/schedule-work.ts new file mode 100644 index 0000000000..a028a249dd --- /dev/null +++ b/packages/core/src/tools/schedule-work.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + BaseDeclarativeTool, + BaseToolInvocation, + type ToolResult, + Kind, +} from './tools.js'; +import type { MessageBus } from '../confirmation-bus/message-bus.js'; +import { SCHEDULE_WORK_TOOL_NAME } from './tool-names.js'; + +export interface ScheduleWorkParams { + inMinutes: number; +} + +export class ScheduleWorkTool extends BaseDeclarativeTool< + ScheduleWorkParams, + ToolResult +> { + constructor(messageBus: MessageBus) { + super( + SCHEDULE_WORK_TOOL_NAME, + 'Schedule Work', + 'Schedule work to resume automatically after a break. Use this to wait for long-running processes or to pause your execution. The system will automatically wake you up.', + Kind.Communicate, + { + type: 'object', + required: ['inMinutes'], + properties: { + inMinutes: { + type: 'number', + description: 'Minutes to wait before automatically resuming work.', + }, + }, + }, + messageBus, + ); + } + + protected override validateToolParamValues( + params: ScheduleWorkParams, + ): string | null { + if (params.inMinutes <= 0) { + return 'inMinutes must be greater than 0.'; + } + return null; + } + + protected createInvocation( + params: ScheduleWorkParams, + messageBus: MessageBus, + toolName: string, + toolDisplayName: string, + ): ScheduleWorkInvocation { + return new ScheduleWorkInvocation( + params, + messageBus, + toolName, + toolDisplayName, + ); + } +} + +export class ScheduleWorkInvocation extends BaseToolInvocation< + ScheduleWorkParams, + ToolResult +> { + getDescription(): string { + return `Scheduling work to resume in ${this.params.inMinutes} minutes.`; + } + + async execute(_signal: AbortSignal): Promise { + return { + llmContent: `Work scheduled. The system will wake you up in ${this.params.inMinutes} minutes. DO NOT make any further tool calls. Instead, provide a brief text summary of the work completed so far to end your turn.`, + returnDisplay: `Scheduled work to resume in ${this.params.inMinutes} minutes.`, + }; + } +} diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index e818881662..8ee1203c99 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -151,6 +151,7 @@ export { }; export const LS_TOOL_NAME_LEGACY = 'list_directory'; // Just to be safe if anything used the old exported name directly +export const SCHEDULE_WORK_TOOL_NAME = 'schedule_work'; export const EDIT_TOOL_NAMES = new Set([EDIT_TOOL_NAME, WRITE_FILE_TOOL_NAME]); @@ -251,6 +252,7 @@ export const ALL_BUILTIN_TOOL_NAMES = [ GET_INTERNAL_DOCS_TOOL_NAME, ENTER_PLAN_MODE_TOOL_NAME, EXIT_PLAN_MODE_TOOL_NAME, + SCHEDULE_WORK_TOOL_NAME, ] as const; /** diff --git a/packages/core/src/utils/sessionUtils.ts b/packages/core/src/utils/sessionUtils.ts index 4803dd4f07..4361ada10c 100644 --- a/packages/core/src/utils/sessionUtils.ts +++ b/packages/core/src/utils/sessionUtils.ts @@ -29,10 +29,16 @@ function ensurePartArray(content: PartListUnion): Part[] { */ export function convertSessionToClientHistory( messages: ConversationRecord['messages'], + startIndex?: number, ): Array<{ role: 'user' | 'model'; parts: Part[] }> { const clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }> = []; - for (const msg of messages) { + const slice = + startIndex != null && startIndex > 0 + ? messages.slice(startIndex) + : messages; + + for (const msg of slice) { if (msg.type === 'info' || msg.type === 'error' || msg.type === 'warning') { continue; }