feat: add Forever Mode for autonomous long-running agent sessions

Add --forever CLI flag that enables autonomous agent operation with
auto-resume, context management, and session optimization.

Core features:
- schedule_work tool: agent declares pause duration, system auto-resumes
  with countdown timer
- PreCompress hook enhancement: hooks can return newHistory to replace
  built-in LLM compression
- Idle hook: fires after configurable inactivity, can auto-submit prompts
- Forever mode disables MemoryTool, EnterPlanModeTool, interactive shell

Session optimization for long-running sessions:
- Record lastCompressionIndex on ConversationRecord; on resume, only load
  post-compression messages (O(N) → O(recent))
- Skip file I/O in updateMessagesFromHistory when no tool results to sync
- Prune UI history to last 50 items after each context compression to
  prevent unbounded memory growth
This commit is contained in:
Sandy Tao
2026-03-08 11:41:36 -07:00
parent 77a874cf65
commit 5194cef9c1
31 changed files with 752 additions and 52 deletions

View File

@@ -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();

View File

@@ -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,
});
}

View File

@@ -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',

View File

@@ -513,6 +513,7 @@ describe('gemini.tsx main function kitty protocol', () => {
rawOutput: undefined,
acceptRawOutputRisk: undefined,
isCommand: undefined,
forever: undefined,
});
await act(async () => {

View File

@@ -222,6 +222,7 @@ export async function runNonInteractive({
await geminiClient.resumeChat(
convertSessionToClientHistory(
resumedSessionData.conversation.messages,
resumedSessionData.conversation.lastCompressionIndex,
),
resumedSessionData,
);

View File

@@ -81,6 +81,7 @@ describe('App', () => {
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
pruneItems: vi.fn(),
},
history: [],
pendingHistoryItems: [],

View File

@@ -1142,6 +1142,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
terminalHeight,
embeddedShellFocused,
consumePendingHints,
historyManager.pruneItems,
);
toggleBackgroundShellRef.current = toggleBackgroundShell;

View File

@@ -57,6 +57,7 @@ describe('handleCreditsFlow', () => {
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
pruneItems: vi.fn(),
};
isDialogPending = { current: false };
mockSetOverageMenuRequest = vi.fn();

View File

@@ -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<string | null>(null);
const [retryStatus, setRetryStatus] = useState<RetryAttemptPayload | null>(
@@ -255,6 +257,16 @@ export const useGeminiStream = (
const [_isFirstToolInGroup, isFirstToolInGroupRef, setIsFirstToolInGroup] =
useStateAndRef<boolean>(true);
const processedMemoryToolsRef = useRef<Set<string>>(new Set());
// Sisyphus Mode: schedule_work auto-resume timer
const sisyphusTargetTimestampRef = useRef<number | null>(null);
const [sisyphusSecondsRemaining, setSisyphusSecondsRemaining] = useState<
number | null
>(null);
const submitQueryRef = useRef<(query: PartListUnion) => Promise<void>>(() =>
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<ReturnType<typeof setTimeout> | 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,
};
};

View File

@@ -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);
});
});
});

View File

@@ -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],
);
}

View File

@@ -91,6 +91,7 @@ describe('useIncludeDirsTrust', () => {
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
pruneItems: vi.fn(),
};
mockSetCustomDialog = vi.fn();
});

View File

@@ -85,6 +85,7 @@ describe('useQuotaAndFallback', () => {
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
pruneItems: vi.fn(),
};
mockSetModelSwitchedFromQuotaError = vi.fn();
mockOnShowAuthSelection = vi.fn();

View File

@@ -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) {

View File

@@ -33,6 +33,7 @@ describe('useSessionResume', () => {
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
pruneItems: vi.fn(),
});
let mockHistoryManager: UseHistoryManagerReturn;

View File

@@ -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,
);
}

View File

@@ -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) {