feat(core): add LegacyAgentSession with agentic loop and rewrite non-interactive CLI consumer

Implements the AgentSession interface for gemini-cli's agentic loop:
- LegacyAgentSession owns send/stream/abort with multi-turn tool scheduling
- Event translator maps all GeminiEventType variants to AgentEvents
- nonInteractiveCli.ts consumes session.stream() instead of manual loop
- Removes dead LocalAgentSessionShim (superseded by LegacyAgentSession)
- 94 tests (68 event-translator + 26 integration/consumer contract)
This commit is contained in:
Adam Weidman
2026-03-16 17:23:47 -04:00
parent b91f75cd6d
commit ea19aeb14b
9 changed files with 3794 additions and 257 deletions
+135 -194
View File
@@ -6,15 +6,14 @@
import type {
Config,
ToolCallRequestInfo,
ResumedSessionData,
UserFeedbackPayload,
ContentPart,
} 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,
@@ -22,17 +21,15 @@ import {
StreamJsonFormatter,
JsonStreamEventType,
uiTelemetryService,
debugLogger,
coreEvents,
CoreEvent,
createWorkingStdio,
recordToolCallInteractions,
ToolErrorType,
Scheduler,
ROOT_SCHEDULER_ID,
LegacyAgentSession,
} from '@google/gemini-cli-core';
import type { Content, Part } from '@google/genai';
import type { Part } from '@google/genai';
import readline from 'node:readline';
import stripAnsi from 'strip-ansi';
@@ -47,6 +44,24 @@ import {
} from './utils/errors.js';
import { TextOutput } from './ui/utils/textOutput.js';
/** Convert @google/genai Part[] → provider-agnostic ContentPart[]. */
function geminiPartsToContentParts(parts: Part[]): ContentPart[] {
return parts.map((part) => {
if (part.text !== undefined) {
return { type: 'text' as const, text: part.text };
}
if (part.inlineData) {
return {
type: 'media' as const,
data: part.inlineData.data,
mimeType: part.inlineData.mimeType,
};
}
// Fallback: serialize as text
return { type: 'text' as const, text: JSON.stringify(part) };
});
}
interface RunNonInteractiveParams {
config: Config;
settings: LoadedSettings;
@@ -286,189 +301,136 @@ export async function runNonInteractive({
});
}
let currentMessages: Content[] = [{ role: 'user', parts: query }];
// Create LegacyAgentSession — owns the agentic loop
const session = new LegacyAgentSession({
client: geminiClient,
scheduler,
config,
promptId: prompt_id,
});
let turnCount = 0;
while (true) {
turnCount++;
if (
config.getMaxSessionTurns() >= 0 &&
turnCount > config.getMaxSessionTurns()
) {
handleMaxTurnsExceededError(config);
}
const toolCallRequests: ToolCallRequestInfo[] = [];
// Wire Ctrl+C to session abort
abortController.signal.addEventListener('abort', () => {
void session.abort();
});
const responseStream = geminiClient.sendMessageStream(
currentMessages[0]?.parts || [],
abortController.signal,
prompt_id,
undefined,
false,
turnCount === 1 ? input : undefined,
);
// Start the agentic loop (runs in background)
await session.send({
message: geminiPartsToContentParts(query),
});
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);
// Consume AgentEvents for output formatting
let responseText = '';
for await (const event of session.stream()) {
switch (event.type) {
case 'message': {
if (event.role === 'agent') {
for (const part of event.content) {
if (part.type === 'text') {
const isRaw =
config.getRawOutput() || config.getAcceptRawOutputRisk();
const output = isRaw ? part.text : stripAnsi(part.text);
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 (part.text) {
textOutput.write(output);
}
}
}
}
}
} else if (event.type === GeminiEventType.ToolCallRequest) {
break;
}
case 'tool_request': {
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,
tool_name: event.name,
tool_id: event.requestId,
parameters: event.args,
});
}
toolCallRequests.push(event.value);
} else if (event.type === GeminiEventType.LoopDetected) {
break;
}
case 'tool_response': {
textOutput.ensureTrailingNewline();
if (streamFormatter) {
const displayText =
event.displayContent?.[0]?.type === 'text'
? event.displayContent[0].text
: undefined;
const errorMsg =
event.content?.[0]?.type === 'text'
? event.content[0].text
: 'Tool error';
streamFormatter.emitEvent({
type: JsonStreamEventType.TOOL_RESULT,
timestamp: new Date().toISOString(),
tool_id: event.requestId,
status: event.isError ? 'error' : 'success',
output: displayText,
error: event.isError
? {
type: 'TOOL_EXECUTION_ERROR',
message: errorMsg,
}
: undefined,
});
}
if (event.isError) {
const displayText =
event.displayContent?.[0]?.type === 'text'
? event.displayContent[0].text
: undefined;
const errorMsg =
event.content?.[0]?.type === 'text'
? event.content[0].text
: 'Tool error';
handleToolError(
event.name,
new Error(errorMsg),
config,
undefined,
displayText,
);
}
break;
}
case 'error': {
if (event.fatal) {
throw new Error(event.message);
}
// Non-fatal errors (e.g. AgentExecutionBlocked): log warning
if (config.getOutputFormat() === OutputFormat.TEXT) {
process.stderr.write(`[WARNING] ${event.message}\n`);
}
if (streamFormatter) {
streamFormatter.emitEvent({
type: JsonStreamEventType.ERROR,
timestamp: new Date().toISOString(),
severity: 'warning',
message: 'Loop detected, stopping execution',
message: event.message,
});
}
} 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`);
}
break;
}
}
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,
});
case 'stream_end': {
if (event.reason === 'aborted') {
handleCancellationError(config);
} else if (event.reason === 'max_turns') {
handleMaxTurnsExceededError(config);
}
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
// Emit final result
if (streamFormatter) {
const metrics = uiTelemetryService.getMetrics();
const durationMs = Date.now() - startTime;
@@ -488,33 +450,12 @@ export async function runNonInteractive({
formatter.format(config.getSessionId(), responseText, stats),
);
} else {
textOutput.ensureTrailingNewline(); // Ensure a final newline
textOutput.ensureTrailingNewline();
}
return;
break;
}
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;
default:
break;
}
}
} catch (error) {