feat(core): add --forever flag and schedule_work tool for autonomous agent mode

- Add schedule_work tool: agent declares pause duration, system auto-resumes
- Add --forever CLI flag with SisyphusModeSettings config
- Forever mode disables MemoryTool, EnterPlanModeTool, interactive shell
- useGeminiStream detects schedule_work calls and runs countdown timer
- Auto-submits resume prompt when timer reaches zero
This commit is contained in:
Sandy Tao
2026-03-09 10:14:53 -07:00
parent 5570b1c046
commit e4cc67b63d
6 changed files with 205 additions and 7 deletions
+15
View File
@@ -93,6 +93,7 @@ export interface CliArgs {
rawOutput: boolean | undefined;
acceptRawOutputRisk: boolean | undefined;
isCommand: boolean | undefined;
forever: boolean | undefined;
}
export async function parseArguments(
@@ -289,6 +290,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
@@ -827,6 +834,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,
});
}
+1
View File
@@ -498,6 +498,7 @@ describe('gemini.tsx main function kitty protocol', () => {
rawOutput: undefined,
acceptRawOutputRisk: undefined,
isCommand: undefined,
forever: undefined,
});
await act(async () => {
+62 -1
View File
@@ -37,6 +37,7 @@ import {
buildUserSteeringHintPrompt,
GeminiCliOperation,
getPlanModeExitMessage,
SCHEDULE_WORK_TOOL_NAME,
} from '@google/gemini-cli-core';
import type {
Config,
@@ -229,6 +230,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);
@@ -1248,6 +1259,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:
@@ -1893,7 +1913,7 @@ export const useGeminiStream = (
// Compute effective timeout: use configured value, or default if
// Idle hooks are registered (e.g. by an extension).
let idleTimeoutSeconds = configuredIdleTimeout;
let idleTimeoutSeconds: number = configuredIdleTimeout;
if (idleTimeoutSeconds <= 0) {
const hookSystem = config.getHookSystem();
const hasIdleHooks = hookSystem
@@ -1932,6 +1952,46 @@ export const useGeminiStream = (
};
}, [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,
@@ -1957,5 +2017,6 @@ export const useGeminiStream = (
backgroundShells,
dismissBackgroundShell,
retryStatus,
sisyphusSecondsRemaining,
};
};
+43 -6
View File
@@ -32,6 +32,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';
@@ -242,6 +243,13 @@ export interface AgentSettings {
browser?: BrowserAgentCustomConfig;
}
export interface SisyphusModeSettings {
enabled: boolean;
idleTimeout?: number;
prompt?: string;
a2aPort?: number;
}
export interface CustomTheme {
type: 'custom';
name: string;
@@ -589,6 +597,8 @@ export interface ConfigParameters {
mcpEnabled?: boolean;
extensionsEnabled?: boolean;
agents?: AgentSettings;
sisyphusMode?: SisyphusModeSettings;
isForeverMode?: boolean;
onReload?: () => Promise<{
disabledSkills?: string[];
adminSkillsEnabled?: boolean;
@@ -788,6 +798,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[];
@@ -885,6 +897,13 @@ export class Config implements McpContext, AgentLoopContext {
this._activeModel = params.model;
this.enableAgents = params.enableAgents ?? false;
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;
@@ -2473,6 +2492,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();
@@ -2558,6 +2585,7 @@ export class Config implements McpContext, AgentLoopContext {
}
isInteractiveShellEnabled(): boolean {
if (this.isForeverMode) return false;
return (
this.interactive &&
this.ptyInfo !== 'child_process' &&
@@ -2859,15 +2887,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)),
@@ -2877,9 +2912,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()) {
+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]);
@@ -228,6 +229,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;
/**