From e4cc67b63d3b683e9ea089cb152f601efd3b0e24 Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Mon, 9 Mar 2026 10:14:53 -0700 Subject: [PATCH] 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 --- packages/cli/src/config/config.ts | 15 ++++ packages/cli/src/gemini.test.tsx | 1 + packages/cli/src/ui/hooks/useGeminiStream.ts | 63 ++++++++++++++- packages/core/src/config/config.ts | 49 ++++++++++-- packages/core/src/tools/schedule-work.ts | 82 ++++++++++++++++++++ packages/core/src/tools/tool-names.ts | 2 + 6 files changed, 205 insertions(+), 7 deletions(-) create mode 100644 packages/core/src/tools/schedule-work.ts diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index a8c85975e9..c68abd3e34 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -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, }); } diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 90c63651e7..d7ce1a1caf 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -498,6 +498,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/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index bd882c6d69..afc03449c4 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -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(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); @@ -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, }; }; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index f615564533..e325b97cf4 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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 { 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()) { 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 fcdcbd6df6..1d9565ff3e 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]); @@ -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; /**