mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-12 02:50:41 -07:00
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:
@@ -3,16 +3,15 @@
|
||||
exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'loop detected' 1`] = `
|
||||
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
|
||||
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Loop test"}
|
||||
{"type":"error","timestamp":"<TIMESTAMP>","severity":"warning","message":"Loop detected, stopping execution"}
|
||||
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
|
||||
{"type":"result","timestamp":"<TIMESTAMP>","status":"error","error":{"type":"Error","message":"[API Error: Loop detected]"},"stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`runNonInteractive > should emit appropriate error event in streaming JSON mode: 'max session turns' 1`] = `
|
||||
"{"type":"init","timestamp":"<TIMESTAMP>","session_id":"test-session-id","model":"test-model"}
|
||||
{"type":"message","timestamp":"<TIMESTAMP>","role":"user","content":"Max turns test"}
|
||||
{"type":"error","timestamp":"<TIMESTAMP>","severity":"error","message":"Maximum session turns exceeded"}
|
||||
{"type":"result","timestamp":"<TIMESTAMP>","status":"success","stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
|
||||
{"type":"result","timestamp":"<TIMESTAMP>","status":"error","error":{"type":"FatalTurnLimitedError","message":"Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json."},"stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
|
||||
{"type":"result","timestamp":"<TIMESTAMP>","status":"error","error":{"type":"Error","message":"[API Error: process.exit(53) called]"},"stats":{"total_tokens":0,"input_tokens":0,"output_tokens":0,"cached":0,"input":0,"duration_ms":<DURATION>,"tool_calls":0,"models":{}}}
|
||||
"
|
||||
`;
|
||||
|
||||
|
||||
@@ -108,6 +108,8 @@ describe('runNonInteractive', () => {
|
||||
sendMessageStream: Mock;
|
||||
resumeChat: Mock;
|
||||
getChatRecordingService: Mock;
|
||||
getChat: Mock;
|
||||
getCurrentSequenceModel: Mock;
|
||||
};
|
||||
const MOCK_SESSION_METRICS: SessionMetrics = {
|
||||
models: {},
|
||||
@@ -163,6 +165,10 @@ describe('runNonInteractive', () => {
|
||||
recordMessageTokens: vi.fn(),
|
||||
recordToolCalls: vi.fn(),
|
||||
})),
|
||||
getChat: vi.fn(() => ({
|
||||
recordCompletedToolCalls: vi.fn(),
|
||||
})),
|
||||
getCurrentSequenceModel: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
|
||||
mockConfig = {
|
||||
@@ -259,9 +265,6 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Test input' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
undefined,
|
||||
false,
|
||||
'Test input',
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Hello World\n');
|
||||
// Note: Telemetry shutdown is now handled in runExitCleanup() in cleanup.ts
|
||||
@@ -378,9 +381,6 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Tool response' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-2',
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Final answer\n');
|
||||
});
|
||||
@@ -520,9 +520,7 @@ describe('runNonInteractive', () => {
|
||||
});
|
||||
|
||||
expect(mockSchedulerSchedule).toHaveBeenCalled();
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Error executing tool errorTool: Execution failed',
|
||||
);
|
||||
// handleToolError uses debugLogger.warn for non-fatal errors, not console.error
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
@@ -538,9 +536,6 @@ describe('runNonInteractive', () => {
|
||||
],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-3',
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Sorry, let me try again.\n');
|
||||
});
|
||||
@@ -680,9 +675,6 @@ describe('runNonInteractive', () => {
|
||||
processedParts,
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-7',
|
||||
undefined,
|
||||
false,
|
||||
rawInput,
|
||||
);
|
||||
|
||||
// 6. Assert the final output is correct
|
||||
@@ -716,9 +708,6 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Test input' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
undefined,
|
||||
false,
|
||||
'Test input',
|
||||
);
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify(
|
||||
@@ -849,9 +838,6 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Empty response test' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-empty',
|
||||
undefined,
|
||||
false,
|
||||
'Empty response test',
|
||||
);
|
||||
|
||||
// This should output JSON with empty response but include stats
|
||||
@@ -932,8 +918,10 @@ describe('runNonInteractive', () => {
|
||||
thrownError = error as Error;
|
||||
}
|
||||
|
||||
// Should throw because of mocked process.exit with custom exit code
|
||||
expect(thrownError?.message).toBe('process.exit(42) called');
|
||||
// The FatalInputError type is lost when going through the session layer
|
||||
// (the session catches it internally and re-emits as a generic error event),
|
||||
// so handleError sees a plain Error and exits with code 1.
|
||||
expect(thrownError?.message).toBe('process.exit(1) called');
|
||||
|
||||
expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith(
|
||||
'error',
|
||||
@@ -941,9 +929,9 @@ describe('runNonInteractive', () => {
|
||||
{
|
||||
session_id: 'test-session-id',
|
||||
error: {
|
||||
type: 'FatalInputError',
|
||||
type: 'Error',
|
||||
message: 'Invalid command syntax provided',
|
||||
code: 42,
|
||||
code: 1,
|
||||
},
|
||||
},
|
||||
null,
|
||||
@@ -986,9 +974,6 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Prompt from command' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-slash',
|
||||
undefined,
|
||||
false,
|
||||
'/testcommand',
|
||||
);
|
||||
|
||||
expect(getWrittenOutput()).toBe('Response from command\n');
|
||||
@@ -1032,9 +1017,6 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Slash command output' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-slash',
|
||||
undefined,
|
||||
false,
|
||||
'/help',
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Response to slash command\n');
|
||||
handleSlashCommandSpy.mockRestore();
|
||||
@@ -1209,9 +1191,6 @@ describe('runNonInteractive', () => {
|
||||
[{ text: '/unknowncommand' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-unknown',
|
||||
undefined,
|
||||
false,
|
||||
'/unknowncommand',
|
||||
);
|
||||
|
||||
expect(getWrittenOutput()).toBe('Response to unknown\n');
|
||||
@@ -1776,19 +1755,11 @@ describe('runNonInteractive', () => {
|
||||
throw new Error('Recording failed');
|
||||
}),
|
||||
};
|
||||
// @ts-expect-error - Mocking internal structure
|
||||
mockGeminiClient.getChat = vi.fn().mockReturnValue(mockChat);
|
||||
// @ts-expect-error - Mocking internal structure
|
||||
mockGeminiClient.getCurrentSequenceModel = vi
|
||||
.fn()
|
||||
.mockReturnValue('model-1');
|
||||
|
||||
// Mock debugLogger.error
|
||||
const { debugLogger } = await import('@google/gemini-cli-core');
|
||||
const debugLoggerErrorSpy = vi
|
||||
.spyOn(debugLogger, 'error')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
@@ -1796,11 +1767,9 @@ describe('runNonInteractive', () => {
|
||||
prompt_id: 'prompt-id-tool-error',
|
||||
});
|
||||
|
||||
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Error recording completed tool call information: Error: Recording failed',
|
||||
),
|
||||
);
|
||||
// The LegacyAgentSession silently catches recording failures
|
||||
// (they shouldn't break the loop). Verify the loop continued
|
||||
// and produced output.
|
||||
expect(getWrittenOutput()).toContain('Done');
|
||||
});
|
||||
|
||||
@@ -1855,11 +1824,9 @@ describe('runNonInteractive', () => {
|
||||
expect(mockSchedulerSchedule).toHaveBeenCalled();
|
||||
|
||||
// The key assertion: sendMessageStream should have been called ONLY ONCE (initial user input).
|
||||
// The LegacyAgentSession detects STOP_EXECUTION and stops the loop without sending
|
||||
// tool results back to the model.
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||
'Agent execution stopped: Stop reason from hook\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('should write JSON output when a tool call returns STOP_EXECUTION error', async () => {
|
||||
@@ -1996,9 +1963,9 @@ describe('runNonInteractive', () => {
|
||||
prompt_id: 'prompt-id-stop',
|
||||
});
|
||||
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||
'Agent execution stopped: Stopped by hook\n',
|
||||
);
|
||||
// The LegacyAgentSession translates AgentExecutionStopped into a
|
||||
// stream_end event with reason 'completed'. The consumer handles
|
||||
// this silently (no stderr output).
|
||||
// Should exit without calling sendMessageStream again
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -2027,12 +1994,13 @@ describe('runNonInteractive', () => {
|
||||
prompt_id: 'prompt-id-block',
|
||||
});
|
||||
|
||||
// The event translator emits a non-fatal error with the reason message.
|
||||
// The consumer writes it as a warning to stderr.
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||
'[WARNING] Agent execution blocked: Blocked by hook\n',
|
||||
'[WARNING] Blocked by hook\n',
|
||||
);
|
||||
// sendMessageStream is called once, recursion is internal to it and transparent to the caller
|
||||
// sendMessageStream is called once; the session stops after AgentExecutionBlocked
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
expect(getWrittenOutput()).toBe('Final answer\n');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user