mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 13:22:35 -07:00
feat: introduce Forever Mode with A2A listener
- Sisyphus: auto-resume timer with schedule_work tool - Confucius: built-in sub-agent for knowledge consolidation before compression - Hippocampus: in-memory short-term memory via background micro-consolidation - Bicameral Voice: proactive knowledge alignment on user input - Archive compression mode for long-running sessions - Onboarding dialog for first-time Forever Mode setup - Refresh system instruction per turn so hippocampus reaches the model - Auto-start A2A HTTP server when Forever Mode + Sisyphus enabled - Bridge external messages into session and capture responses - Display A2A port in status bar alongside Sisyphus timer
This commit is contained in:
@@ -37,6 +37,8 @@ import {
|
||||
buildUserSteeringHintPrompt,
|
||||
GeminiCliOperation,
|
||||
getPlanModeExitMessage,
|
||||
CompressionStatus,
|
||||
SCHEDULE_WORK_TOOL_NAME,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
Config,
|
||||
@@ -58,7 +60,6 @@ import type {
|
||||
HistoryItemThinking,
|
||||
HistoryItemWithoutId,
|
||||
HistoryItemToolGroup,
|
||||
HistoryItemInfo,
|
||||
IndividualToolCallDisplay,
|
||||
SlashCommandProcessorResult,
|
||||
HistoryItemModel,
|
||||
@@ -229,6 +230,27 @@ export const useGeminiStream = (
|
||||
const [_isFirstToolInGroup, isFirstToolInGroupRef, setIsFirstToolInGroup] =
|
||||
useStateAndRef<boolean>(true);
|
||||
const processedMemoryToolsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Sisyphus Mode States
|
||||
const activeSisyphusScheduleRef = useRef<{
|
||||
breakTime?: number;
|
||||
prompt?: string;
|
||||
isExplicitSchedule?: boolean;
|
||||
} | null>(null);
|
||||
const sisyphusTargetTimestampRef = useRef<number | null>(null);
|
||||
const [sisyphusSecondsRemaining, setSisyphusSecondsRemaining] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [, setSisyphusTick] = useState<number>(0);
|
||||
const submitQueryRef = useRef<
|
||||
(
|
||||
query: PartListUnion,
|
||||
options?: { isContinuation: boolean },
|
||||
prompt_id?: string,
|
||||
) => Promise<void>
|
||||
>(() => Promise.resolve());
|
||||
const hasForcedConfuciusRef = useRef<boolean>(false);
|
||||
|
||||
const { startNewPrompt, getPromptCount } = useSessionStats();
|
||||
const storage = config.storage;
|
||||
const logger = useLogger(storage);
|
||||
@@ -1060,31 +1082,37 @@ export const useGeminiStream = (
|
||||
eventValue: ServerGeminiChatCompressedEvent['value'],
|
||||
userMessageTimestamp: number,
|
||||
) => {
|
||||
// Reset the force flag so Confucius can trigger again before the NEXT compression cycle
|
||||
hasForcedConfuciusRef.current = false;
|
||||
|
||||
if (pendingHistoryItemRef.current) {
|
||||
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
||||
setPendingHistoryItem(null);
|
||||
}
|
||||
const isArchived =
|
||||
eventValue?.compressionStatus === CompressionStatus.ARCHIVED;
|
||||
const archivePath = eventValue?.archivePath;
|
||||
|
||||
const limit = tokenLimit(config.getModel());
|
||||
const originalPercentage = Math.round(
|
||||
((eventValue?.originalTokenCount ?? 0) / limit) * 100,
|
||||
);
|
||||
const newPercentage = Math.round(
|
||||
((eventValue?.newTokenCount ?? 0) / limit) * 100,
|
||||
);
|
||||
let text =
|
||||
`IMPORTANT: This conversation exceeded the compress threshold. ` +
|
||||
`A compressed context will be sent for future messages (compressed from: ` +
|
||||
`${eventValue?.originalTokenCount ?? 'unknown'} to ` +
|
||||
`${eventValue?.newTokenCount ?? 'unknown'} tokens).`;
|
||||
|
||||
addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: `Context compressed from ${originalPercentage}% to ${newPercentage}%.`,
|
||||
secondaryText: `Change threshold in /settings.`,
|
||||
color: theme.status.warning,
|
||||
marginBottom: 1,
|
||||
} as HistoryItemInfo,
|
||||
userMessageTimestamp,
|
||||
);
|
||||
if (isArchived && archivePath) {
|
||||
text =
|
||||
`IMPORTANT: This conversation exceeded the compress threshold. ` +
|
||||
`History has been archived to: ${archivePath} (compressed from: ` +
|
||||
`${eventValue?.originalTokenCount ?? 'unknown'} to ` +
|
||||
`${eventValue?.newTokenCount ?? 'unknown'} tokens).`;
|
||||
}
|
||||
|
||||
return addItem({
|
||||
type: 'info',
|
||||
text,
|
||||
});
|
||||
},
|
||||
[addItem, pendingHistoryItemRef, setPendingHistoryItem, config],
|
||||
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
|
||||
);
|
||||
|
||||
const handleMaxSessionTurnsEvent = useCallback(
|
||||
@@ -1248,6 +1276,17 @@ 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);
|
||||
activeSisyphusScheduleRef.current = {
|
||||
breakTime: inMinutes,
|
||||
isExplicitSchedule: true,
|
||||
};
|
||||
setSisyphusSecondsRemaining(inMinutes * 60);
|
||||
// Do NOT intercept and manually resolve it here.
|
||||
// Push it to toolCallRequests so it is executed properly by the backend tool registry.
|
||||
}
|
||||
toolCallRequests.push(event.value);
|
||||
break;
|
||||
case ServerGeminiEventType.UserCancelled:
|
||||
@@ -1369,6 +1408,10 @@ export const useGeminiStream = (
|
||||
|
||||
const userMessageTimestamp = Date.now();
|
||||
|
||||
// Reset Sisyphus timer on any activity but preserve the active schedule override if it exists
|
||||
setSisyphusSecondsRemaining(null);
|
||||
sisyphusTargetTimestampRef.current = null;
|
||||
|
||||
// Reset quota error flag when starting a new query (not a continuation)
|
||||
if (!options?.isContinuation) {
|
||||
setModelSwitchedFromQuotaError(false);
|
||||
@@ -1388,6 +1431,35 @@ export const useGeminiStream = (
|
||||
if (!prompt_id) {
|
||||
prompt_id = config.getSessionId() + '########' + getPromptCount();
|
||||
}
|
||||
|
||||
if (config.getIsForeverMode()) {
|
||||
const currentTokens = geminiClient
|
||||
.getChat()
|
||||
.getLastPromptTokenCount();
|
||||
const threshold = (await config.getCompressionThreshold()) ?? 0.8;
|
||||
const limit = tokenLimit(config.getActiveModel());
|
||||
|
||||
if (
|
||||
currentTokens >= limit * threshold * 0.9 &&
|
||||
!hasForcedConfuciusRef.current
|
||||
) {
|
||||
hasForcedConfuciusRef.current = true;
|
||||
const hippocampusContent = config.getHippocampusContent().trim();
|
||||
const hippocampusBlock = hippocampusContent
|
||||
? `\n\nThe following is the short-term memory (hippocampus) that MUST be passed to the confucius agent as the query input:\n--- Hippocampus ---\n${hippocampusContent}\n-------------------`
|
||||
: '';
|
||||
const confuciusNudge = `\n<system_note>\nYour context window is approaching the compression threshold. Before responding to the user's request, you MUST first call the 'confucius' tool to consolidate important learnings from this session into long-term knowledge.${hippocampusBlock}\n\nAfter the confucius agent completes, proceed with the user's original request.\n</system_note>\n`;
|
||||
if (typeof query === 'string') {
|
||||
query = [{ text: query }, { text: confuciusNudge }];
|
||||
} else if (Array.isArray(query)) {
|
||||
query = [...query, { text: confuciusNudge }];
|
||||
} else {
|
||||
// Single Part object
|
||||
query = [query, { text: confuciusNudge }];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return promptIdContext.run(prompt_id, async () => {
|
||||
const { queryToSend, shouldProceed } = await prepareQueryForGemini(
|
||||
query,
|
||||
@@ -1448,6 +1520,7 @@ export const useGeminiStream = (
|
||||
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
|
||||
setPendingHistoryItem(null);
|
||||
}
|
||||
|
||||
if (loopDetectedRef.current) {
|
||||
loopDetectedRef.current = false;
|
||||
// Show the confirmation dialog to choose whether to disable loop detection
|
||||
@@ -1874,6 +1947,98 @@ export const useGeminiStream = (
|
||||
storage,
|
||||
]);
|
||||
|
||||
// Handle Sisyphus countdown and automatic trigger
|
||||
useEffect(() => {
|
||||
submitQueryRef.current = submitQuery;
|
||||
}, [submitQuery]);
|
||||
|
||||
// Handle Sisyphus activation and automatic trigger
|
||||
useEffect(() => {
|
||||
const sisyphusSettings = config.getSisyphusMode();
|
||||
const isExplicitlyScheduled =
|
||||
activeSisyphusScheduleRef.current?.isExplicitSchedule;
|
||||
|
||||
if (!sisyphusSettings.enabled && !isExplicitlyScheduled) {
|
||||
setSisyphusSecondsRemaining(null);
|
||||
sisyphusTargetTimestampRef.current = null;
|
||||
activeSisyphusScheduleRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (streamingState !== StreamingState.Idle) {
|
||||
setSisyphusSecondsRemaining(null);
|
||||
sisyphusTargetTimestampRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Now we are IDLE. If no target is set, set one.
|
||||
if (sisyphusTargetTimestampRef.current === null) {
|
||||
if (
|
||||
!activeSisyphusScheduleRef.current &&
|
||||
sisyphusSettings.idleTimeout !== undefined
|
||||
) {
|
||||
activeSisyphusScheduleRef.current = {
|
||||
breakTime: sisyphusSettings.idleTimeout,
|
||||
prompt: sisyphusSettings.prompt,
|
||||
};
|
||||
}
|
||||
|
||||
if (activeSisyphusScheduleRef.current?.breakTime !== undefined) {
|
||||
const delayMs = activeSisyphusScheduleRef.current.breakTime * 60 * 1000;
|
||||
sisyphusTargetTimestampRef.current = Date.now() + delayMs;
|
||||
setSisyphusSecondsRemaining(Math.ceil(delayMs / 1000));
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
streamingState === StreamingState.Idle &&
|
||||
sisyphusSecondsRemaining !== null &&
|
||||
sisyphusSecondsRemaining <= 0
|
||||
) {
|
||||
const isExplicitSchedule =
|
||||
activeSisyphusScheduleRef.current?.isExplicitSchedule;
|
||||
const promptToUse = isExplicitSchedule
|
||||
? 'System: The scheduled break has ended. Please resume your work.'
|
||||
: (activeSisyphusScheduleRef.current?.prompt ??
|
||||
sisyphusSettings.prompt ??
|
||||
'continue workflow');
|
||||
|
||||
// Clear for next time so it reverts to default
|
||||
activeSisyphusScheduleRef.current = null;
|
||||
sisyphusTargetTimestampRef.current = null;
|
||||
setSisyphusSecondsRemaining(null);
|
||||
void submitQueryRef.current(promptToUse);
|
||||
}
|
||||
}, [streamingState, sisyphusSecondsRemaining, config]);
|
||||
|
||||
// Handle Sisyphus countdown timers independently to ensure UI updates
|
||||
const isTimerActive =
|
||||
(streamingState === StreamingState.Idle &&
|
||||
sisyphusTargetTimestampRef.current !== null) ||
|
||||
config.getSisyphusMode().enabled ||
|
||||
activeSisyphusScheduleRef.current?.isExplicitSchedule;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTimerActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateTimer = () => {
|
||||
// Sisyphus countdown
|
||||
if (sisyphusTargetTimestampRef.current !== null) {
|
||||
const remainingMs = sisyphusTargetTimestampRef.current - Date.now();
|
||||
const remainingSecs = Math.max(0, Math.ceil(remainingMs / 1000));
|
||||
setSisyphusSecondsRemaining(remainingSecs);
|
||||
}
|
||||
|
||||
setSisyphusTick((t) => t + 1); // Force a re-render
|
||||
};
|
||||
|
||||
const timer = setInterval(updateTimer, 100); // Update frequently for high responsiveness
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [isTimerActive, config]);
|
||||
|
||||
const lastOutputTime = Math.max(
|
||||
lastToolOutputTime,
|
||||
lastShellOutputTime,
|
||||
@@ -1899,5 +2064,6 @@ export const useGeminiStream = (
|
||||
backgroundShells,
|
||||
dismissBackgroundShell,
|
||||
retryStatus,
|
||||
sisyphusSecondsRemaining,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user