mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-18 18:11:02 -07:00
On resume (-r), the CLI was loading and replaying the entire session recording, including messages that had already been compressed away. For long-running Forever Mode sessions this made resume extremely slow. Add lastCompressionIndex to ConversationRecord, stamped when compression succeeds. On resume, only messages from that index onward are loaded into the client history and UI. Fully backward compatible — old sessions without the field load all messages as before.
536 lines
18 KiB
TypeScript
536 lines
18 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import type {
|
|
Config,
|
|
ToolCallRequestInfo,
|
|
ResumedSessionData,
|
|
UserFeedbackPayload,
|
|
} from '@google/gemini-cli-core';
|
|
import { isSlashCommand } from './ui/utils/commandUtils.js';
|
|
import type { LoadedSettings } from './config/settings.js';
|
|
import {
|
|
convertSessionToClientHistory,
|
|
GeminiEventType,
|
|
FatalInputError,
|
|
promptIdContext,
|
|
OutputFormat,
|
|
JsonFormatter,
|
|
StreamJsonFormatter,
|
|
JsonStreamEventType,
|
|
uiTelemetryService,
|
|
debugLogger,
|
|
coreEvents,
|
|
CoreEvent,
|
|
createWorkingStdio,
|
|
recordToolCallInteractions,
|
|
ToolErrorType,
|
|
Scheduler,
|
|
ROOT_SCHEDULER_ID,
|
|
} from '@google/gemini-cli-core';
|
|
|
|
import type { Content, Part } from '@google/genai';
|
|
import readline from 'node:readline';
|
|
import stripAnsi from 'strip-ansi';
|
|
|
|
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
|
|
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
|
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
|
|
import {
|
|
handleError,
|
|
handleToolError,
|
|
handleCancellationError,
|
|
handleMaxTurnsExceededError,
|
|
} from './utils/errors.js';
|
|
import { TextOutput } from './ui/utils/textOutput.js';
|
|
|
|
interface RunNonInteractiveParams {
|
|
config: Config;
|
|
settings: LoadedSettings;
|
|
input: string;
|
|
prompt_id: string;
|
|
resumedSessionData?: ResumedSessionData;
|
|
}
|
|
|
|
export async function runNonInteractive({
|
|
config,
|
|
settings,
|
|
input,
|
|
prompt_id,
|
|
resumedSessionData,
|
|
}: RunNonInteractiveParams): Promise<void> {
|
|
return promptIdContext.run(prompt_id, async () => {
|
|
const consolePatcher = new ConsolePatcher({
|
|
stderr: true,
|
|
debugMode: config.getDebugMode(),
|
|
onNewMessage: (msg) => {
|
|
coreEvents.emitConsoleLog(msg.type, msg.content);
|
|
},
|
|
});
|
|
|
|
if (process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']) {
|
|
const { setupInitialActivityLogger } = await import(
|
|
'./utils/devtoolsService.js'
|
|
);
|
|
await setupInitialActivityLogger(config);
|
|
}
|
|
|
|
const { stdout: workingStdout } = createWorkingStdio();
|
|
const textOutput = new TextOutput(workingStdout);
|
|
|
|
const handleUserFeedback = (payload: UserFeedbackPayload) => {
|
|
const prefix = payload.severity.toUpperCase();
|
|
process.stderr.write(`[${prefix}] ${payload.message}\n`);
|
|
if (payload.error && config.getDebugMode()) {
|
|
const errorToLog =
|
|
payload.error instanceof Error
|
|
? payload.error.stack || payload.error.message
|
|
: String(payload.error);
|
|
process.stderr.write(`${errorToLog}\n`);
|
|
}
|
|
};
|
|
|
|
const startTime = Date.now();
|
|
const streamFormatter =
|
|
config.getOutputFormat() === OutputFormat.STREAM_JSON
|
|
? new StreamJsonFormatter()
|
|
: null;
|
|
|
|
const abortController = new AbortController();
|
|
|
|
// Track cancellation state
|
|
let isAborting = false;
|
|
let cancelMessageTimer: NodeJS.Timeout | null = null;
|
|
|
|
// Setup stdin listener for Ctrl+C detection
|
|
let stdinWasRaw = false;
|
|
let rl: readline.Interface | null = null;
|
|
|
|
const setupStdinCancellation = () => {
|
|
// Only setup if stdin is a TTY (user can interact)
|
|
if (!process.stdin.isTTY) {
|
|
return;
|
|
}
|
|
|
|
// Save original raw mode state
|
|
stdinWasRaw = process.stdin.isRaw || false;
|
|
|
|
// Enable raw mode to capture individual keypresses
|
|
process.stdin.setRawMode(true);
|
|
process.stdin.resume();
|
|
|
|
// Setup readline to emit keypress events
|
|
rl = readline.createInterface({
|
|
input: process.stdin,
|
|
escapeCodeTimeout: 0,
|
|
});
|
|
readline.emitKeypressEvents(process.stdin, rl);
|
|
|
|
// Listen for Ctrl+C
|
|
const keypressHandler = (
|
|
str: string,
|
|
key: { name?: string; ctrl?: boolean },
|
|
) => {
|
|
// Detect Ctrl+C: either ctrl+c key combo or raw character code 3
|
|
if ((key && key.ctrl && key.name === 'c') || str === '\u0003') {
|
|
// Only handle once
|
|
if (isAborting) {
|
|
return;
|
|
}
|
|
|
|
isAborting = true;
|
|
|
|
// Only show message if cancellation takes longer than 200ms
|
|
// This reduces verbosity for fast cancellations
|
|
cancelMessageTimer = setTimeout(() => {
|
|
process.stderr.write('\nCancelling...\n');
|
|
}, 200);
|
|
|
|
abortController.abort();
|
|
// Note: Don't exit here - let the abort flow through the system
|
|
// and trigger handleCancellationError() which will exit with proper code
|
|
}
|
|
};
|
|
|
|
process.stdin.on('keypress', keypressHandler);
|
|
};
|
|
|
|
const cleanupStdinCancellation = () => {
|
|
// Clear any pending cancel message timer
|
|
if (cancelMessageTimer) {
|
|
clearTimeout(cancelMessageTimer);
|
|
cancelMessageTimer = null;
|
|
}
|
|
|
|
// Cleanup readline and stdin listeners
|
|
if (rl) {
|
|
rl.close();
|
|
rl = null;
|
|
}
|
|
|
|
// Remove keypress listener
|
|
process.stdin.removeAllListeners('keypress');
|
|
|
|
// Restore stdin to original state
|
|
if (process.stdin.isTTY) {
|
|
process.stdin.setRawMode(stdinWasRaw);
|
|
process.stdin.pause();
|
|
}
|
|
};
|
|
|
|
let errorToHandle: unknown | undefined;
|
|
try {
|
|
consolePatcher.patch();
|
|
|
|
if (
|
|
config.getRawOutput() &&
|
|
!config.getAcceptRawOutputRisk() &&
|
|
config.getOutputFormat() === OutputFormat.TEXT
|
|
) {
|
|
process.stderr.write(
|
|
'[WARNING] --raw-output is enabled. Model output is not sanitized and may contain harmful ANSI sequences (e.g. for phishing or command injection). Use --accept-raw-output-risk to suppress this warning.\n',
|
|
);
|
|
}
|
|
|
|
// Setup stdin cancellation listener
|
|
setupStdinCancellation();
|
|
|
|
coreEvents.on(CoreEvent.UserFeedback, handleUserFeedback);
|
|
coreEvents.drainBacklogs();
|
|
|
|
// Handle EPIPE errors when the output is piped to a command that closes early.
|
|
process.stdout.on('error', (err: NodeJS.ErrnoException) => {
|
|
if (err.code === 'EPIPE') {
|
|
// Exit gracefully if the pipe is closed.
|
|
process.exit(0);
|
|
}
|
|
});
|
|
|
|
const geminiClient = config.getGeminiClient();
|
|
const scheduler = new Scheduler({
|
|
config,
|
|
messageBus: config.getMessageBus(),
|
|
getPreferredEditor: () => undefined,
|
|
schedulerId: ROOT_SCHEDULER_ID,
|
|
});
|
|
|
|
// Initialize chat. Resume if resume data is passed.
|
|
if (resumedSessionData) {
|
|
await geminiClient.resumeChat(
|
|
convertSessionToClientHistory(
|
|
resumedSessionData.conversation.messages,
|
|
resumedSessionData.conversation.lastCompressionIndex,
|
|
),
|
|
resumedSessionData,
|
|
);
|
|
}
|
|
|
|
// Emit init event for streaming JSON
|
|
if (streamFormatter) {
|
|
streamFormatter.emitEvent({
|
|
type: JsonStreamEventType.INIT,
|
|
timestamp: new Date().toISOString(),
|
|
session_id: config.getSessionId(),
|
|
model: config.getModel(),
|
|
});
|
|
}
|
|
|
|
let query: Part[] | undefined;
|
|
|
|
if (isSlashCommand(input)) {
|
|
const slashCommandResult = await handleSlashCommand(
|
|
input,
|
|
abortController,
|
|
config,
|
|
settings,
|
|
);
|
|
// If a slash command is found and returns a prompt, use it.
|
|
// Otherwise, slashCommandResult falls through to the default prompt
|
|
// handling.
|
|
if (slashCommandResult) {
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
query = slashCommandResult as Part[];
|
|
}
|
|
}
|
|
|
|
if (!query) {
|
|
const { processedQuery, error } = await handleAtCommand({
|
|
query: input,
|
|
config,
|
|
addItem: (_item, _timestamp) => 0,
|
|
onDebugMessage: () => {},
|
|
messageId: Date.now(),
|
|
signal: abortController.signal,
|
|
});
|
|
|
|
if (error || !processedQuery) {
|
|
// An error occurred during @include processing (e.g., file not found).
|
|
// The error message is already logged by handleAtCommand.
|
|
throw new FatalInputError(
|
|
error || 'Exiting due to an error processing the @ command.',
|
|
);
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
query = processedQuery as Part[];
|
|
}
|
|
|
|
// Emit user message event for streaming JSON
|
|
if (streamFormatter) {
|
|
streamFormatter.emitEvent({
|
|
type: JsonStreamEventType.MESSAGE,
|
|
timestamp: new Date().toISOString(),
|
|
role: 'user',
|
|
content: input,
|
|
});
|
|
}
|
|
|
|
let currentMessages: Content[] = [{ role: 'user', parts: query }];
|
|
|
|
let turnCount = 0;
|
|
while (true) {
|
|
turnCount++;
|
|
if (
|
|
config.getMaxSessionTurns() >= 0 &&
|
|
turnCount > config.getMaxSessionTurns()
|
|
) {
|
|
handleMaxTurnsExceededError(config);
|
|
}
|
|
const toolCallRequests: ToolCallRequestInfo[] = [];
|
|
|
|
const responseStream = geminiClient.sendMessageStream(
|
|
currentMessages[0]?.parts || [],
|
|
abortController.signal,
|
|
prompt_id,
|
|
undefined,
|
|
false,
|
|
turnCount === 1 ? input : undefined,
|
|
);
|
|
|
|
let responseText = '';
|
|
for await (const event of responseStream) {
|
|
if (abortController.signal.aborted) {
|
|
handleCancellationError(config);
|
|
}
|
|
|
|
if (event.type === GeminiEventType.Content) {
|
|
const isRaw =
|
|
config.getRawOutput() || config.getAcceptRawOutputRisk();
|
|
const output = isRaw ? event.value : stripAnsi(event.value);
|
|
if (streamFormatter) {
|
|
streamFormatter.emitEvent({
|
|
type: JsonStreamEventType.MESSAGE,
|
|
timestamp: new Date().toISOString(),
|
|
role: 'assistant',
|
|
content: output,
|
|
delta: true,
|
|
});
|
|
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
|
responseText += output;
|
|
} else {
|
|
if (event.value) {
|
|
textOutput.write(output);
|
|
}
|
|
}
|
|
} else if (event.type === GeminiEventType.ToolCallRequest) {
|
|
if (streamFormatter) {
|
|
streamFormatter.emitEvent({
|
|
type: JsonStreamEventType.TOOL_USE,
|
|
timestamp: new Date().toISOString(),
|
|
tool_name: event.value.name,
|
|
tool_id: event.value.callId,
|
|
parameters: event.value.args,
|
|
});
|
|
}
|
|
toolCallRequests.push(event.value);
|
|
} else if (event.type === GeminiEventType.LoopDetected) {
|
|
if (streamFormatter) {
|
|
streamFormatter.emitEvent({
|
|
type: JsonStreamEventType.ERROR,
|
|
timestamp: new Date().toISOString(),
|
|
severity: 'warning',
|
|
message: 'Loop detected, stopping execution',
|
|
});
|
|
}
|
|
} else if (event.type === GeminiEventType.MaxSessionTurns) {
|
|
if (streamFormatter) {
|
|
streamFormatter.emitEvent({
|
|
type: JsonStreamEventType.ERROR,
|
|
timestamp: new Date().toISOString(),
|
|
severity: 'error',
|
|
message: 'Maximum session turns exceeded',
|
|
});
|
|
}
|
|
} else if (event.type === GeminiEventType.Error) {
|
|
throw event.value.error;
|
|
} else if (event.type === GeminiEventType.AgentExecutionStopped) {
|
|
const stopMessage = `Agent execution stopped: ${event.value.systemMessage?.trim() || event.value.reason}`;
|
|
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
|
process.stderr.write(`${stopMessage}\n`);
|
|
}
|
|
// Emit final result event for streaming JSON if needed
|
|
if (streamFormatter) {
|
|
const metrics = uiTelemetryService.getMetrics();
|
|
const durationMs = Date.now() - startTime;
|
|
streamFormatter.emitEvent({
|
|
type: JsonStreamEventType.RESULT,
|
|
timestamp: new Date().toISOString(),
|
|
status: 'success',
|
|
stats: streamFormatter.convertToStreamStats(
|
|
metrics,
|
|
durationMs,
|
|
),
|
|
});
|
|
}
|
|
return;
|
|
} else if (event.type === GeminiEventType.AgentExecutionBlocked) {
|
|
const blockMessage = `Agent execution blocked: ${event.value.systemMessage?.trim() || event.value.reason}`;
|
|
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
|
process.stderr.write(`[WARNING] ${blockMessage}\n`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (toolCallRequests.length > 0) {
|
|
textOutput.ensureTrailingNewline();
|
|
const completedToolCalls = await scheduler.schedule(
|
|
toolCallRequests,
|
|
abortController.signal,
|
|
);
|
|
const toolResponseParts: Part[] = [];
|
|
|
|
for (const completedToolCall of completedToolCalls) {
|
|
const toolResponse = completedToolCall.response;
|
|
const requestInfo = completedToolCall.request;
|
|
|
|
if (streamFormatter) {
|
|
streamFormatter.emitEvent({
|
|
type: JsonStreamEventType.TOOL_RESULT,
|
|
timestamp: new Date().toISOString(),
|
|
tool_id: requestInfo.callId,
|
|
status:
|
|
completedToolCall.status === 'error' ? 'error' : 'success',
|
|
output:
|
|
typeof toolResponse.resultDisplay === 'string'
|
|
? toolResponse.resultDisplay
|
|
: undefined,
|
|
error: toolResponse.error
|
|
? {
|
|
type: toolResponse.errorType || 'TOOL_EXECUTION_ERROR',
|
|
message: toolResponse.error.message,
|
|
}
|
|
: undefined,
|
|
});
|
|
}
|
|
|
|
if (toolResponse.error) {
|
|
handleToolError(
|
|
requestInfo.name,
|
|
toolResponse.error,
|
|
config,
|
|
toolResponse.errorType || 'TOOL_EXECUTION_ERROR',
|
|
typeof toolResponse.resultDisplay === 'string'
|
|
? toolResponse.resultDisplay
|
|
: undefined,
|
|
);
|
|
}
|
|
|
|
if (toolResponse.responseParts) {
|
|
toolResponseParts.push(...toolResponse.responseParts);
|
|
}
|
|
}
|
|
|
|
// Record tool calls with full metadata before sending responses to Gemini
|
|
try {
|
|
const currentModel =
|
|
geminiClient.getCurrentSequenceModel() ?? config.getModel();
|
|
geminiClient
|
|
.getChat()
|
|
.recordCompletedToolCalls(currentModel, completedToolCalls);
|
|
|
|
await recordToolCallInteractions(config, completedToolCalls);
|
|
} catch (error) {
|
|
debugLogger.error(
|
|
`Error recording completed tool call information: ${error}`,
|
|
);
|
|
}
|
|
|
|
// Check if any tool requested to stop execution immediately
|
|
const stopExecutionTool = completedToolCalls.find(
|
|
(tc) => tc.response.errorType === ToolErrorType.STOP_EXECUTION,
|
|
);
|
|
|
|
if (stopExecutionTool && stopExecutionTool.response.error) {
|
|
const stopMessage = `Agent execution stopped: ${stopExecutionTool.response.error.message}`;
|
|
|
|
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
|
process.stderr.write(`${stopMessage}\n`);
|
|
}
|
|
|
|
// Emit final result event for streaming JSON
|
|
if (streamFormatter) {
|
|
const metrics = uiTelemetryService.getMetrics();
|
|
const durationMs = Date.now() - startTime;
|
|
streamFormatter.emitEvent({
|
|
type: JsonStreamEventType.RESULT,
|
|
timestamp: new Date().toISOString(),
|
|
status: 'success',
|
|
stats: streamFormatter.convertToStreamStats(
|
|
metrics,
|
|
durationMs,
|
|
),
|
|
});
|
|
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
|
const formatter = new JsonFormatter();
|
|
const stats = uiTelemetryService.getMetrics();
|
|
textOutput.write(
|
|
formatter.format(config.getSessionId(), responseText, stats),
|
|
);
|
|
} else {
|
|
textOutput.ensureTrailingNewline(); // Ensure a final newline
|
|
}
|
|
return;
|
|
}
|
|
|
|
currentMessages = [{ role: 'user', parts: toolResponseParts }];
|
|
} else {
|
|
// Emit final result event for streaming JSON
|
|
if (streamFormatter) {
|
|
const metrics = uiTelemetryService.getMetrics();
|
|
const durationMs = Date.now() - startTime;
|
|
streamFormatter.emitEvent({
|
|
type: JsonStreamEventType.RESULT,
|
|
timestamp: new Date().toISOString(),
|
|
status: 'success',
|
|
stats: streamFormatter.convertToStreamStats(metrics, durationMs),
|
|
});
|
|
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
|
const formatter = new JsonFormatter();
|
|
const stats = uiTelemetryService.getMetrics();
|
|
textOutput.write(
|
|
formatter.format(config.getSessionId(), responseText, stats),
|
|
);
|
|
} else {
|
|
textOutput.ensureTrailingNewline(); // Ensure a final newline
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
errorToHandle = error;
|
|
} finally {
|
|
// Cleanup stdin cancellation before other cleanup
|
|
cleanupStdinCancellation();
|
|
|
|
consolePatcher.cleanup();
|
|
coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback);
|
|
}
|
|
|
|
if (errorToHandle) {
|
|
handleError(errorToHandle, config);
|
|
}
|
|
});
|
|
}
|