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
+43 -6
View File
@@ -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<boolean | undefined> {
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()) {
+3
View File
@@ -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,
+1
View File
@@ -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();
+3
View File
@@ -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 {
@@ -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<AggregatedHookResult> {
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<AggregatedHookResult> {
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
+8 -1
View File
@@ -232,8 +232,15 @@ export class HookSystem {
async firePreCompressEvent(
trigger: PreCompressTrigger,
history: Array<{ role: string; parts: Array<{ text?: string }> }>,
): Promise<AggregatedHookResult | undefined> {
return this.hookEventHandler.firePreCompressEvent(trigger);
return this.hookEventHandler.firePreCompressEvent(trigger, history);
}
async fireIdleEvent(
idleSeconds: number,
): Promise<AggregatedHookResult | undefined> {
return this.hookEventHandler.fireIdleEvent(idleSeconds);
}
async fireBeforeAgentEvent(
+1
View File
@@ -57,6 +57,7 @@ describe('Hook Types', () => {
'BeforeModel',
'AfterModel',
'BeforeToolSelection',
'Idle',
];
for (const event of expectedEvents) {
+31 -2
View File
@@ -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;
};
}
/**
@@ -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<Config['getHookSystem']>,
);
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<Config['getHookSystem']>,
);
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<Config['getHookSystem']>,
);
await service.compress(
mockChat,
mockPromptId,
true,
mockModel,
mockConfig,
false,
);
expect(mockHookSystem.firePreCompressEvent).toHaveBeenCalledWith(
'manual',
[
{ role: 'user', parts: [{ text: 'hello' }] },
{ role: 'model', parts: [{ text: 'world' }] },
],
);
});
});
});
@@ -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(
@@ -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<string, Part[]>();
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<string, Part[]>();
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) {
+82
View File
@@ -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<ToolResult> {
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.`,
};
}
}
+2
View File
@@ -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;
/**
+7 -1
View File
@@ -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;
}