mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-23 19:44:30 -07:00
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:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -513,6 +513,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
||||
rawOutput: undefined,
|
||||
acceptRawOutputRisk: undefined,
|
||||
isCommand: undefined,
|
||||
forever: undefined,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -222,6 +222,7 @@ export async function runNonInteractive({
|
||||
await geminiClient.resumeChat(
|
||||
convertSessionToClientHistory(
|
||||
resumedSessionData.conversation.messages,
|
||||
resumedSessionData.conversation.lastCompressionIndex,
|
||||
),
|
||||
resumedSessionData,
|
||||
);
|
||||
|
||||
@@ -81,6 +81,7 @@ describe('App', () => {
|
||||
updateItem: vi.fn(),
|
||||
clearItems: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
pruneItems: vi.fn(),
|
||||
},
|
||||
history: [],
|
||||
pendingHistoryItems: [],
|
||||
|
||||
@@ -1142,6 +1142,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
terminalHeight,
|
||||
embeddedShellFocused,
|
||||
consumePendingHints,
|
||||
historyManager.pruneItems,
|
||||
);
|
||||
|
||||
toggleBackgroundShellRef.current = toggleBackgroundShell;
|
||||
|
||||
@@ -57,6 +57,7 @@ describe('handleCreditsFlow', () => {
|
||||
updateItem: vi.fn(),
|
||||
clearItems: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
pruneItems: vi.fn(),
|
||||
};
|
||||
isDialogPending = { current: false };
|
||||
mockSetOverageMenuRequest = vi.fn();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ describe('useIncludeDirsTrust', () => {
|
||||
updateItem: vi.fn(),
|
||||
clearItems: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
pruneItems: vi.fn(),
|
||||
};
|
||||
mockSetCustomDialog = vi.fn();
|
||||
});
|
||||
|
||||
@@ -85,6 +85,7 @@ describe('useQuotaAndFallback', () => {
|
||||
updateItem: vi.fn(),
|
||||
clearItems: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
pruneItems: vi.fn(),
|
||||
};
|
||||
mockSetModelSwitchedFromQuotaError = vi.fn();
|
||||
mockOnShowAuthSelection = vi.fn();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -33,6 +33,7 @@ describe('useSessionResume', () => {
|
||||
updateItem: vi.fn(),
|
||||
clearItems: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
pruneItems: vi.fn(),
|
||||
});
|
||||
|
||||
let mockHistoryManager: UseHistoryManagerReturn;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -57,6 +57,7 @@ describe('Hook Types', () => {
|
||||
'BeforeModel',
|
||||
'AfterModel',
|
||||
'BeforeToolSelection',
|
||||
'Idle',
|
||||
];
|
||||
|
||||
for (const event of expectedEvents) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user