mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-28 02:31:05 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e62920f1f5 | |||
| 6b44dfee4c | |||
| b23bcc7ae5 |
@@ -832,6 +832,12 @@ export async function loadCliConfig(
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
experimentalJitContext: settings.experimental?.jitContext,
|
||||
modelSteering: settings.experimental?.modelSteering,
|
||||
useAgentFactoryAll: settings.experimental?.useAgentFactoryAll,
|
||||
useAgentFactorySdk: settings.experimental?.useAgentFactorySdk,
|
||||
useAgentFactoryNonInteractive:
|
||||
settings.experimental?.useAgentFactoryNonInteractive,
|
||||
useAgentFactoryInteractive:
|
||||
settings.experimental?.useAgentFactoryInteractive,
|
||||
toolOutputMasking: settings.experimental?.toolOutputMasking,
|
||||
noBrowser: !!process.env['NO_BROWSER'],
|
||||
summarizeToolOutput: settings.model?.summarizeToolOutput,
|
||||
|
||||
@@ -1703,6 +1703,44 @@ const SETTINGS_SCHEMA = {
|
||||
'Enable model steering (user hints) to guide the model during tool execution.',
|
||||
showInDialog: true,
|
||||
},
|
||||
useAgentFactoryAll: {
|
||||
type: 'boolean',
|
||||
label: 'Use Agent Factory (All)',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enable Agent Factory for all supported execution paths.',
|
||||
showInDialog: true,
|
||||
},
|
||||
useAgentFactorySdk: {
|
||||
type: 'boolean',
|
||||
label: 'Use Agent Factory (SDK)',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enable Agent Factory for the SDK execution path.',
|
||||
showInDialog: true,
|
||||
},
|
||||
useAgentFactoryNonInteractive: {
|
||||
type: 'boolean',
|
||||
label: 'Use Agent Factory (Non-Interactive)',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable Agent Factory for the non-interactive CLI execution path.',
|
||||
showInDialog: true,
|
||||
},
|
||||
useAgentFactoryInteractive: {
|
||||
type: 'boolean',
|
||||
label: 'Use Agent Factory (Interactive)',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description:
|
||||
'Enable Agent Factory for the interactive CLI execution path.',
|
||||
showInDialog: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -82,6 +82,19 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
stdout: process.stdout,
|
||||
stderr: process.stderr,
|
||||
})),
|
||||
AgentSession: vi.fn(),
|
||||
AgentTerminateMode: {
|
||||
ERROR: 'ERROR',
|
||||
TIMEOUT: 'TIMEOUT',
|
||||
GOAL: 'GOAL',
|
||||
MAX_TURNS: 'MAX_TURNS',
|
||||
ABORTED: 'ABORTED',
|
||||
},
|
||||
CoreToolCallStatus: {
|
||||
Success: 'success',
|
||||
Error: 'error',
|
||||
Cancelled: 'cancelled',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -190,6 +203,7 @@ describe('runNonInteractive', () => {
|
||||
isTrustedFolder: vi.fn().mockReturnValue(false),
|
||||
getRawOutput: vi.fn().mockReturnValue(false),
|
||||
getAcceptRawOutputRisk: vi.fn().mockReturnValue(false),
|
||||
isAgentsEnabled: vi.fn().mockReturnValue(false),
|
||||
} as unknown as Config;
|
||||
|
||||
mockSettings = {
|
||||
@@ -2231,4 +2245,106 @@ describe('runNonInteractive', () => {
|
||||
expect(output).toContain('"status":"success"');
|
||||
});
|
||||
});
|
||||
describe('runNonInteractive (AgentSession)', () => {
|
||||
let mockAgentSession: { prompt: Mock; resume: Mock };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.mocked(mockConfig.isAgentsEnabled).mockReturnValue(true);
|
||||
|
||||
// Get the mocked AgentSession class to spy on instances
|
||||
const core = await import('@google/gemini-cli-core');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const MockAgentSessionClass = core.AgentSession as any;
|
||||
|
||||
// Mock the prompt method logic
|
||||
mockAgentSession = {
|
||||
prompt: vi.fn(),
|
||||
resume: vi.fn(),
|
||||
};
|
||||
|
||||
// When new AgentSession() is called, return our mock instance
|
||||
MockAgentSessionClass.mockImplementation(() => mockAgentSession);
|
||||
});
|
||||
|
||||
it('should process input and write text output from agent events', async () => {
|
||||
const events = [
|
||||
{ type: GeminiEventType.Content, value: 'Hello' },
|
||||
{ type: GeminiEventType.Content, value: ' World' },
|
||||
{
|
||||
type: 'agent_finish',
|
||||
value: {
|
||||
reason: 'GOAL',
|
||||
sessionId: 'test-session-id',
|
||||
totalTurns: 1,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
async function* eventGenerator() {
|
||||
for (const event of events) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
|
||||
mockAgentSession.prompt.mockReturnValue(eventGenerator());
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'Test input',
|
||||
prompt_id: 'prompt-id-agent',
|
||||
});
|
||||
|
||||
expect(mockAgentSession.prompt).toHaveBeenCalledWith(
|
||||
[{ text: 'Test input' }],
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Hello World\n');
|
||||
});
|
||||
|
||||
it('should write JSON output with stats from agent events', async () => {
|
||||
const events = [
|
||||
{ type: GeminiEventType.Content, value: 'JSON Response' },
|
||||
{
|
||||
type: 'agent_finish',
|
||||
value: {
|
||||
reason: 'GOAL',
|
||||
sessionId: 'test-session-id',
|
||||
totalTurns: 1,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
async function* eventGenerator() {
|
||||
for (const event of events) {
|
||||
yield event;
|
||||
}
|
||||
}
|
||||
|
||||
mockAgentSession.prompt.mockReturnValue(eventGenerator());
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'Test input',
|
||||
prompt_id: 'prompt-id-agent-json',
|
||||
});
|
||||
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify(
|
||||
{
|
||||
session_id: 'test-session-id',
|
||||
response: 'JSON Response',
|
||||
stats: MOCK_SESSION_METRICS,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,18 +24,20 @@ import {
|
||||
debugLogger,
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
createWorkingStdio,
|
||||
recordToolCallInteractions,
|
||||
ToolErrorType,
|
||||
Scheduler,
|
||||
ROOT_SCHEDULER_ID,
|
||||
convertSessionToClientHistory,
|
||||
createWorkingStdio,
|
||||
AgentSession,
|
||||
AgentTerminateMode,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import type { Content, Part } from '@google/genai';
|
||||
import readline from 'node:readline';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
|
||||
import { convertSessionToHistoryFormats } from './ui/hooks/useSessionBrowser.js';
|
||||
import { handleSlashCommand } from './nonInteractiveCliCommands.js';
|
||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||
import { handleAtCommand } from './ui/hooks/atCommandProcessor.js';
|
||||
@@ -55,6 +57,16 @@ interface RunNonInteractiveParams {
|
||||
resumedSessionData?: ResumedSessionData;
|
||||
}
|
||||
|
||||
interface LoopContext {
|
||||
consolePatcher: ConsolePatcher;
|
||||
textOutput: TextOutput;
|
||||
abortController: AbortController;
|
||||
streamFormatter: StreamJsonFormatter | null;
|
||||
startTime: number;
|
||||
setupStdinCancellation: () => void;
|
||||
cleanupStdinCancellation: () => void;
|
||||
}
|
||||
|
||||
export async function runNonInteractive({
|
||||
config,
|
||||
settings,
|
||||
@@ -209,36 +221,20 @@ export async function runNonInteractive({
|
||||
}
|
||||
});
|
||||
|
||||
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(
|
||||
convertSessionToHistoryFormats(
|
||||
resumedSessionData.conversation.messages,
|
||||
).clientHistory,
|
||||
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(),
|
||||
});
|
||||
}
|
||||
const loopContext: LoopContext = {
|
||||
consolePatcher,
|
||||
textOutput,
|
||||
abortController,
|
||||
streamFormatter,
|
||||
startTime,
|
||||
setupStdinCancellation,
|
||||
cleanupStdinCancellation,
|
||||
};
|
||||
|
||||
// --- Input Processing (Lifted) ---
|
||||
let query: Part[] | undefined;
|
||||
|
||||
// 1. Slash Commands
|
||||
if (isSlashCommand(input)) {
|
||||
const slashCommandResult = await handleSlashCommand(
|
||||
input,
|
||||
@@ -247,14 +243,13 @@ export async function runNonInteractive({
|
||||
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[];
|
||||
}
|
||||
}
|
||||
|
||||
// 2. @ Commands (if query not set by slash command)
|
||||
if (!query) {
|
||||
const { processedQuery, error } = await handleAtCommand({
|
||||
query: input,
|
||||
@@ -266,8 +261,6 @@ export async function runNonInteractive({
|
||||
});
|
||||
|
||||
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.',
|
||||
);
|
||||
@@ -276,246 +269,24 @@ export async function runNonInteractive({
|
||||
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,
|
||||
});
|
||||
}
|
||||
// --- Dispatch Loop ---
|
||||
const experimental = settings.experimental;
|
||||
const useAgentFactory =
|
||||
experimental?.useAgentFactoryAll ||
|
||||
experimental?.useAgentFactoryNonInteractive;
|
||||
|
||||
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,
|
||||
if (useAgentFactory) {
|
||||
await runAgentSessionFlow(
|
||||
loopContext,
|
||||
{ config, settings, input, prompt_id, resumedSessionData, query },
|
||||
handleUserFeedback,
|
||||
);
|
||||
} else {
|
||||
await runLegacyManualLoop(
|
||||
loopContext,
|
||||
{ config, settings, input, prompt_id, resumedSessionData, query },
|
||||
handleUserFeedback,
|
||||
);
|
||||
|
||||
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;
|
||||
@@ -532,3 +303,436 @@ export async function runNonInteractive({
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function runAgentSessionFlow(
|
||||
{ textOutput, abortController, streamFormatter, startTime }: LoopContext,
|
||||
{
|
||||
config,
|
||||
settings: _settings,
|
||||
input,
|
||||
prompt_id: _prompt_id,
|
||||
resumedSessionData,
|
||||
query,
|
||||
}: RunNonInteractiveParams & { query: Part[] },
|
||||
_handleUserFeedback: (payload: UserFeedbackPayload) => void,
|
||||
) {
|
||||
const session = new AgentSession(
|
||||
config.getSessionId(),
|
||||
{
|
||||
name: 'cli-agent',
|
||||
maxTurns: config.getMaxSessionTurns(),
|
||||
},
|
||||
config,
|
||||
);
|
||||
|
||||
if (resumedSessionData) {
|
||||
await session.resume(resumedSessionData);
|
||||
}
|
||||
|
||||
let finalResponseText = '';
|
||||
|
||||
// Handle initialization for stream JSON format
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.INIT,
|
||||
timestamp: new Date().toISOString(),
|
||||
session_id: config.getSessionId(),
|
||||
model: config.getModel(),
|
||||
});
|
||||
}
|
||||
|
||||
// NOTE: Input processing (Slash commands, @ commands) is now handled in `runNonInteractive`
|
||||
// and passed in via `query`.
|
||||
|
||||
// Emit user message event for streaming JSON
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.MESSAGE,
|
||||
timestamp: new Date().toISOString(),
|
||||
role: 'user',
|
||||
content: input,
|
||||
});
|
||||
}
|
||||
|
||||
// Start Agent Loop
|
||||
const stream = session.prompt(query, abortController.signal);
|
||||
|
||||
for await (const event of stream) {
|
||||
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) {
|
||||
finalResponseText += 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,
|
||||
});
|
||||
}
|
||||
} else if (event.type === 'tool_suite_finish') {
|
||||
// Replicates the "TOOL_RESULT" emission from legacy loop
|
||||
// The legacy loop emits this *after* execution.
|
||||
// AgentSession emits 'tool_suite_finish' after execution.
|
||||
for (const response of event.value.responses) {
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.TOOL_RESULT,
|
||||
timestamp: new Date().toISOString(),
|
||||
tool_id: response.callId,
|
||||
status: response.error ? 'error' : 'success',
|
||||
output:
|
||||
typeof response.resultDisplay === 'string'
|
||||
? response.resultDisplay
|
||||
: undefined,
|
||||
error: response.error
|
||||
? {
|
||||
type: response.errorType || 'TOOL_EXECUTION_ERROR',
|
||||
message: response.error.message,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
// Handle explicit error printing for TEXT mode
|
||||
if (response.error && config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
handleToolError(
|
||||
response.callId, // Using callId as name fallback since name is not available in response
|
||||
new Error(response.error.message),
|
||||
config,
|
||||
response.errorType || 'TOOL_EXECUTION_ERROR',
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (event.type === 'agent_finish') {
|
||||
const { reason, message, error: _error } = event.value;
|
||||
|
||||
if (reason === AgentTerminateMode.MAX_TURNS) {
|
||||
handleMaxTurnsExceededError(config);
|
||||
} else if (
|
||||
reason === AgentTerminateMode.ERROR ||
|
||||
reason === AgentTerminateMode.ABORTED
|
||||
) {
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT && message) {
|
||||
process.stderr.write(`Agent execution stopped: ${message}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit Final JSON
|
||||
if (streamFormatter) {
|
||||
const metrics = uiTelemetryService.getMetrics();
|
||||
const durationMs = Date.now() - startTime;
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.RESULT,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: reason === AgentTerminateMode.ERROR ? 'error' : '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(), finalResponseText, stats),
|
||||
);
|
||||
} else {
|
||||
textOutput.ensureTrailingNewline();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runLegacyManualLoop(
|
||||
{
|
||||
textOutput,
|
||||
abortController,
|
||||
streamFormatter,
|
||||
startTime,
|
||||
cleanupStdinCancellation: _cleanupStdinCancellation,
|
||||
}: LoopContext,
|
||||
{
|
||||
config,
|
||||
settings: _settings,
|
||||
input,
|
||||
prompt_id,
|
||||
resumedSessionData,
|
||||
query,
|
||||
}: RunNonInteractiveParams & { query: Part[] },
|
||||
_handleUserFeedback: (payload: UserFeedbackPayload) => void,
|
||||
) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
// Emit init event for streaming JSON
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.INIT,
|
||||
timestamp: new Date().toISOString(),
|
||||
session_id: config.getSessionId(),
|
||||
model: config.getModel(),
|
||||
});
|
||||
}
|
||||
|
||||
// NOTE: Input processing now handled upstream in `runNonInteractive`
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
RewindEvent,
|
||||
type ChatRecordingService,
|
||||
type GeminiClient,
|
||||
convertSessionToClientHistory,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
/**
|
||||
@@ -54,9 +55,8 @@ async function rewindConversation(
|
||||
}
|
||||
|
||||
// Convert to UI and Client formats
|
||||
const { uiHistory, clientHistory } = convertSessionToHistoryFormats(
|
||||
conversation.messages,
|
||||
);
|
||||
const { uiHistory } = convertSessionToHistoryFormats(conversation.messages);
|
||||
const clientHistory = convertSessionToClientHistory(conversation.messages);
|
||||
|
||||
client.setHistory(clientHistory as Content[]);
|
||||
|
||||
|
||||
@@ -6,17 +6,26 @@ AppHeader(full)
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ Line 1 │
|
||||
│ Line 2 │
|
||||
│ Line 3 │
|
||||
│ Line 4 │
|
||||
│ Line 5 │
|
||||
│ Line 6 │
|
||||
│ Line 7 │
|
||||
│ Line 8 │
|
||||
│ Line 9 │
|
||||
│ Line 10 │
|
||||
│ Line 11 │
|
||||
│ Line 12 │
|
||||
│ Line 13 │
|
||||
│ Line 14 │
|
||||
│ Line 15 █ │
|
||||
│ Line 16 █ │
|
||||
│ Line 17 █ │
|
||||
│ Line 18 █ │
|
||||
│ Line 19 █ │
|
||||
│ Line 20 █ │
|
||||
│ Line 15 │
|
||||
│ Line 16 │
|
||||
│ Line 17 │
|
||||
│ Line 18 │
|
||||
│ Line 19 │
|
||||
│ Line 20 │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
ShowMoreLines
|
||||
"
|
||||
@@ -28,11 +37,15 @@ AppHeader(full)
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ Line 10 │
|
||||
│ Line 11 │
|
||||
│ Line 12 │
|
||||
│ Line 13 │
|
||||
│ Line 14 │
|
||||
│ Line 6 │
|
||||
│ Line 7 │
|
||||
│ Line 8 │
|
||||
│ Line 9 ▄ │
|
||||
│ Line 10 █ │
|
||||
│ Line 11 █ │
|
||||
│ Line 12 █ │
|
||||
│ Line 13 █ │
|
||||
│ Line 14 █ │
|
||||
│ Line 15 █ │
|
||||
│ Line 16 █ │
|
||||
│ Line 17 █ │
|
||||
@@ -49,7 +62,12 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ ... first 11 lines hidden ... │
|
||||
│ Line 6 │
|
||||
│ Line 7 │
|
||||
│ Line 8 │
|
||||
│ Line 9 │
|
||||
│ Line 10 │
|
||||
│ Line 11 │
|
||||
│ Line 12 │
|
||||
│ Line 13 │
|
||||
│ Line 14 │
|
||||
@@ -69,11 +87,6 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unc
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ Line 1 │
|
||||
│ Line 2 │
|
||||
│ Line 3 │
|
||||
│ Line 4 │
|
||||
│ Line 5 │
|
||||
│ Line 6 │
|
||||
│ Line 7 │
|
||||
│ Line 8 │
|
||||
|
||||
@@ -50,6 +50,9 @@ import type {
|
||||
ToolCallResponseInfo,
|
||||
GeminiErrorEventValue,
|
||||
RetryAttemptPayload,
|
||||
AgentSession,
|
||||
AgentTerminateMode,
|
||||
AgentEvent,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { type Part, type PartListUnion, FinishReason } from '@google/genai';
|
||||
import type {
|
||||
@@ -1250,7 +1253,102 @@ export const useGeminiStream = (
|
||||
setPendingHistoryItem,
|
||||
setThought,
|
||||
],
|
||||
);
|
||||
|
||||
const processAgentEvents = useCallback(
|
||||
async (
|
||||
stream: AsyncIterable<AgentEvent>,
|
||||
userMessageTimestamp: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> => {
|
||||
let geminiMessageBuffer = '';
|
||||
for await (const event of stream) {
|
||||
if (signal.aborted) break;
|
||||
|
||||
// Map AgentEvent back to GeminiEvent handlers
|
||||
switch (event.type) {
|
||||
case 'thought':
|
||||
handleThoughtEvent(
|
||||
{ summary: event.value, thought: event.value },
|
||||
userMessageTimestamp,
|
||||
);
|
||||
break;
|
||||
case ServerGeminiEventType.Content:
|
||||
geminiMessageBuffer = handleContentEvent(
|
||||
event.value,
|
||||
geminiMessageBuffer,
|
||||
userMessageTimestamp,
|
||||
);
|
||||
break;
|
||||
case ServerGeminiEventType.ToolCallRequest:
|
||||
// Handled by AgentSession, but we can still show them
|
||||
// The useToolScheduler will be used by AgentSession's internal scheduler,
|
||||
// but for UI feedback we need to make sure they show up in toolCalls.
|
||||
// Since AgentSession uses Scheduler which is not hooked into useToolScheduler state,
|
||||
// we might need to bridge this.
|
||||
// For now, we'll just emit events to show activity.
|
||||
break;
|
||||
case 'tool_suite_start':
|
||||
setToolCallsForDisplay(
|
||||
Array(event.value.count).fill({
|
||||
status: CoreToolCallStatus.Executing,
|
||||
request: { name: 'Executing tools...' },
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case 'tool_suite_finish':
|
||||
setToolCallsForDisplay([]);
|
||||
// handleCompletedTools will be called by AgentSession internally,
|
||||
// but we need to update the UI history here.
|
||||
// AgentSession doesn't provide the full TrackedToolCall objects.
|
||||
// This is a known gap in the "meet in the middle" approach.
|
||||
break;
|
||||
case 'agent_finish': {
|
||||
const { reason } = event.value;
|
||||
if (reason === AgentTerminateMode.MAX_TURNS) {
|
||||
handleMaxSessionTurnsEvent();
|
||||
}
|
||||
setIsResponding(false);
|
||||
break;
|
||||
}
|
||||
case 'goal_completed':
|
||||
addItem({
|
||||
type: MessageType.INFO,
|
||||
text: 'Goal completed.',
|
||||
});
|
||||
break;
|
||||
case ServerGeminiEventType.Error:
|
||||
handleErrorEvent(event.value, userMessageTimestamp);
|
||||
break;
|
||||
default: {
|
||||
// Handle other core events if they match
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const coreEvent = event as unknown as {
|
||||
type: ServerGeminiEventType;
|
||||
value: unknown;
|
||||
};
|
||||
if (coreEvent.type === ServerGeminiEventType.Citation) {
|
||||
handleCitationEvent(
|
||||
coreEvent.value as unknown as string, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion
|
||||
userMessageTimestamp,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
handleThoughtEvent,
|
||||
handleContentEvent,
|
||||
handleMaxSessionTurnsEvent,
|
||||
handleErrorEvent,
|
||||
handleCitationEvent,
|
||||
setToolCallsForDisplay,
|
||||
setIsResponding,
|
||||
addItem,
|
||||
],
|
||||
);
|
||||
|
||||
const submitQuery = useCallback(
|
||||
async (
|
||||
query: PartListUnion,
|
||||
@@ -1323,23 +1421,45 @@ export const useGeminiStream = (
|
||||
lastQueryRef.current = queryToSend;
|
||||
lastPromptIdRef.current = prompt_id!;
|
||||
|
||||
try {
|
||||
const stream = geminiClient.sendMessageStream(
|
||||
queryToSend,
|
||||
abortSignal,
|
||||
prompt_id!,
|
||||
undefined,
|
||||
false,
|
||||
query,
|
||||
);
|
||||
const processingStatus = await processGeminiStreamEvents(
|
||||
stream,
|
||||
userMessageTimestamp,
|
||||
abortSignal,
|
||||
);
|
||||
const experimental = settings.experimental;
|
||||
const useAgentFactory =
|
||||
experimental?.useAgentFactoryAll ||
|
||||
experimental?.useAgentFactoryInteractive;
|
||||
|
||||
if (processingStatus === StreamProcessingStatus.UserCancelled) {
|
||||
return;
|
||||
try {
|
||||
if (useAgentFactory) {
|
||||
const session = new AgentSession(
|
||||
config.getSessionId(),
|
||||
{
|
||||
name: 'interactive-agent',
|
||||
maxTurns: config.getMaxSessionTurns(),
|
||||
},
|
||||
config,
|
||||
);
|
||||
const stream = session.prompt(queryToSend, abortSignal);
|
||||
await processAgentEvents(
|
||||
stream,
|
||||
userMessageTimestamp,
|
||||
abortSignal,
|
||||
);
|
||||
} else {
|
||||
const stream = geminiClient.sendMessageStream(
|
||||
queryToSend,
|
||||
abortSignal,
|
||||
prompt_id!,
|
||||
undefined,
|
||||
false,
|
||||
query,
|
||||
);
|
||||
const processingStatus = await processGeminiStreamEvents(
|
||||
stream,
|
||||
userMessageTimestamp,
|
||||
abortSignal,
|
||||
);
|
||||
|
||||
if (processingStatus === StreamProcessingStatus.UserCancelled) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingHistoryItemRef.current) {
|
||||
@@ -1420,6 +1540,8 @@ export const useGeminiStream = (
|
||||
setModelSwitchedFromQuotaError,
|
||||
prepareQueryForGemini,
|
||||
processGeminiStreamEvents,
|
||||
processAgentEvents,
|
||||
settings.experimental,
|
||||
pendingHistoryItemRef,
|
||||
addItem,
|
||||
setPendingHistoryItem,
|
||||
|
||||
@@ -20,7 +20,10 @@ import {
|
||||
type MessageRecord,
|
||||
CoreToolCallStatus,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { coreEvents } from '@google/gemini-cli-core';
|
||||
import {
|
||||
coreEvents,
|
||||
convertSessionToClientHistory,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
// Mock modules
|
||||
vi.mock('fs/promises');
|
||||
@@ -157,7 +160,7 @@ describe('convertSessionToHistoryFormats', () => {
|
||||
it('should convert empty messages array', () => {
|
||||
const result = convertSessionToHistoryFormats([]);
|
||||
expect(result.uiHistory).toEqual([]);
|
||||
expect(result.clientHistory).toEqual([]);
|
||||
expect(convertSessionToClientHistory([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('should convert basic user and model messages', () => {
|
||||
@@ -175,12 +178,13 @@ describe('convertSessionToHistoryFormats', () => {
|
||||
text: 'Hi there',
|
||||
});
|
||||
|
||||
expect(result.clientHistory).toHaveLength(2);
|
||||
expect(result.clientHistory[0]).toEqual({
|
||||
const clientHistory = convertSessionToClientHistory(messages);
|
||||
expect(clientHistory).toHaveLength(2);
|
||||
expect(clientHistory[0]).toEqual({
|
||||
role: 'user',
|
||||
parts: [{ text: 'Hello' }],
|
||||
});
|
||||
expect(result.clientHistory[1]).toEqual({
|
||||
expect(clientHistory[1]).toEqual({
|
||||
role: 'model',
|
||||
parts: [{ text: 'Hi there' }],
|
||||
});
|
||||
@@ -203,8 +207,9 @@ describe('convertSessionToHistoryFormats', () => {
|
||||
text: 'User input',
|
||||
});
|
||||
|
||||
expect(result.clientHistory).toHaveLength(1);
|
||||
expect(result.clientHistory[0]).toEqual({
|
||||
const clientHistory = convertSessionToClientHistory(messages);
|
||||
expect(clientHistory).toHaveLength(1);
|
||||
expect(clientHistory[0]).toEqual({
|
||||
role: 'user',
|
||||
parts: [{ text: 'Expanded content' }],
|
||||
});
|
||||
@@ -225,7 +230,7 @@ describe('convertSessionToHistoryFormats', () => {
|
||||
text: 'Help text',
|
||||
});
|
||||
|
||||
expect(result.clientHistory).toHaveLength(0);
|
||||
expect(convertSessionToClientHistory(messages)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle tool calls and responses', () => {
|
||||
@@ -264,12 +269,13 @@ describe('convertSessionToHistoryFormats', () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.clientHistory).toHaveLength(3); // User, Model (call), User (response)
|
||||
expect(result.clientHistory[0]).toEqual({
|
||||
const clientHistory = convertSessionToClientHistory(messages);
|
||||
expect(clientHistory).toHaveLength(3); // User, Model (call), User (response)
|
||||
expect(clientHistory[0]).toEqual({
|
||||
role: 'user',
|
||||
parts: [{ text: 'What time is it?' }],
|
||||
});
|
||||
expect(result.clientHistory[1]).toEqual({
|
||||
expect(clientHistory[1]).toEqual({
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
@@ -281,7 +287,7 @@ describe('convertSessionToHistoryFormats', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.clientHistory[2]).toEqual({
|
||||
expect(clientHistory[2]).toEqual({
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
|
||||
@@ -13,7 +13,10 @@ import type {
|
||||
ConversationRecord,
|
||||
ResumedSessionData,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { coreEvents } from '@google/gemini-cli-core';
|
||||
import {
|
||||
coreEvents,
|
||||
convertSessionToClientHistory,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { SessionInfo } from '../../utils/sessionUtils.js';
|
||||
import { convertSessionToHistoryFormats } from '../../utils/sessionUtils.js';
|
||||
import type { Part } from '@google/genai';
|
||||
@@ -78,7 +81,7 @@ export const useSessionBrowser = (
|
||||
);
|
||||
await onLoadHistory(
|
||||
historyData.uiHistory,
|
||||
historyData.clientHistory,
|
||||
convertSessionToClientHistory(conversation.messages),
|
||||
resumedSessionData,
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
coreEvents,
|
||||
type Config,
|
||||
type ResumedSessionData,
|
||||
convertSessionToClientHistory,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import type { HistoryItemWithoutId } from '../types.js';
|
||||
@@ -113,7 +114,7 @@ export function useSessionResume({
|
||||
);
|
||||
void loadHistoryForResume(
|
||||
historyData.uiHistory,
|
||||
historyData.clientHistory,
|
||||
convertSessionToClientHistory(resumedSessionData.conversation.messages),
|
||||
resumedSessionData,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
import * as fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { stripUnsafeCharacters } from '../ui/utils/textUtils.js';
|
||||
import type { Part } from '@google/genai';
|
||||
import { MessageType, type HistoryItemWithoutId } from '../ui/types.js';
|
||||
|
||||
/**
|
||||
@@ -526,13 +525,12 @@ export class SessionSelector {
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts session/conversation data into UI history and Gemini client history formats.
|
||||
* Converts session/conversation data into UI history format.
|
||||
*/
|
||||
export function convertSessionToHistoryFormats(
|
||||
messages: ConversationRecord['messages'],
|
||||
): {
|
||||
uiHistory: HistoryItemWithoutId[];
|
||||
clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }>;
|
||||
} {
|
||||
const uiHistory: HistoryItemWithoutId[] = [];
|
||||
|
||||
@@ -599,117 +597,7 @@ export function convertSessionToHistoryFormats(
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to Gemini client history format
|
||||
const clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }> = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
// Skip system/error messages and user slash commands
|
||||
if (msg.type === 'info' || msg.type === 'error' || msg.type === 'warning') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.type === 'user') {
|
||||
// Skip user slash commands
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
if (
|
||||
contentString.trim().startsWith('/') ||
|
||||
contentString.trim().startsWith('?')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add regular user message
|
||||
clientHistory.push({
|
||||
role: 'user',
|
||||
parts: Array.isArray(msg.content)
|
||||
? // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(msg.content as Part[])
|
||||
: [{ text: contentString }],
|
||||
});
|
||||
} else if (msg.type === 'gemini') {
|
||||
// Handle Gemini messages with potential tool calls
|
||||
const hasToolCalls = msg.toolCalls && msg.toolCalls.length > 0;
|
||||
|
||||
if (hasToolCalls) {
|
||||
// Create model message with function calls
|
||||
const modelParts: Part[] = [];
|
||||
|
||||
// Add text content if present
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
if (msg.content && contentString.trim()) {
|
||||
modelParts.push({ text: contentString });
|
||||
}
|
||||
|
||||
// Add function calls
|
||||
for (const toolCall of msg.toolCalls!) {
|
||||
modelParts.push({
|
||||
functionCall: {
|
||||
name: toolCall.name,
|
||||
args: toolCall.args,
|
||||
...(toolCall.id && { id: toolCall.id }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
clientHistory.push({
|
||||
role: 'model',
|
||||
parts: modelParts,
|
||||
});
|
||||
|
||||
// Create single function response message with all tool call responses
|
||||
const functionResponseParts: Part[] = [];
|
||||
for (const toolCall of msg.toolCalls!) {
|
||||
if (toolCall.result) {
|
||||
// Convert PartListUnion result to function response format
|
||||
let responseData: Part;
|
||||
|
||||
if (typeof toolCall.result === 'string') {
|
||||
responseData = {
|
||||
functionResponse: {
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
response: {
|
||||
output: toolCall.result,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (Array.isArray(toolCall.result)) {
|
||||
// toolCall.result is an array containing properly formatted
|
||||
// function responses
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
functionResponseParts.push(...(toolCall.result as Part[]));
|
||||
continue;
|
||||
} else {
|
||||
// Fallback for non-array results
|
||||
responseData = toolCall.result;
|
||||
}
|
||||
|
||||
functionResponseParts.push(responseData);
|
||||
}
|
||||
}
|
||||
|
||||
// Only add user message if we have function responses
|
||||
if (functionResponseParts.length > 0) {
|
||||
clientHistory.push({
|
||||
role: 'user',
|
||||
parts: functionResponseParts,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Regular Gemini message without tool calls
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
if (msg.content && contentString.trim()) {
|
||||
clientHistory.push({
|
||||
role: 'model',
|
||||
parts: [{ text: contentString }],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uiHistory,
|
||||
clientHistory,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
SessionSelector,
|
||||
convertSessionToHistoryFormats,
|
||||
} from '../utils/sessionUtils.js';
|
||||
import { convertSessionToClientHistory } from '@google/gemini-cli-core';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
|
||||
vi.mock('../config/config.js', () => ({
|
||||
@@ -42,6 +43,15 @@ vi.mock('../utils/sessionUtils.js', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
convertSessionToClientHistory: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('GeminiAgent Session Resume', () => {
|
||||
let mockConfig: Mocked<Config>;
|
||||
let mockSettings: Mocked<LoadedSettings>;
|
||||
@@ -142,9 +152,11 @@ describe('GeminiAgent Session Resume', () => {
|
||||
{ role: 'model', parts: [{ text: 'Hi there' }] },
|
||||
];
|
||||
(convertSessionToHistoryFormats as unknown as Mock).mockReturnValue({
|
||||
clientHistory: mockClientHistory,
|
||||
uiHistory: [],
|
||||
});
|
||||
(convertSessionToClientHistory as unknown as Mock).mockReturnValue(
|
||||
mockClientHistory,
|
||||
);
|
||||
|
||||
const response = await agent.loadSession({
|
||||
sessionId,
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
partListUnionToString,
|
||||
LlmRole,
|
||||
ApprovalMode,
|
||||
convertSessionToClientHistory,
|
||||
} from '@google/gemini-cli-core';
|
||||
import * as acp from '@agentclientprotocol/sdk';
|
||||
import { AcpFileSystemService } from './fileSystemService.js';
|
||||
@@ -53,10 +54,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import type { CliArgs } from '../config/config.js';
|
||||
import { loadCliConfig } from '../config/config.js';
|
||||
import { runExitCleanup } from '../utils/cleanup.js';
|
||||
import {
|
||||
SessionSelector,
|
||||
convertSessionToHistoryFormats,
|
||||
} from '../utils/sessionUtils.js';
|
||||
import { SessionSelector } from '../utils/sessionUtils.js';
|
||||
|
||||
export async function runZedIntegration(
|
||||
config: Config,
|
||||
@@ -258,9 +256,7 @@ export class GeminiAgent {
|
||||
config.setFileSystemService(acpFileSystemService);
|
||||
}
|
||||
|
||||
const { clientHistory } = convertSessionToHistoryFormats(
|
||||
sessionData.messages,
|
||||
);
|
||||
const clientHistory = convertSessionToClientHistory(sessionData.messages);
|
||||
|
||||
const geminiClient = config.getGeminiClient();
|
||||
await geminiClient.initialize();
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { Agent } from './agent.js';
|
||||
import { AgentSession } from './session.js';
|
||||
import { makeFakeConfig } from '../test-utils/config.js';
|
||||
import { type AgentConfig } from './types.js';
|
||||
|
||||
vi.mock('./session.js', () => ({
|
||||
AgentSession: vi.fn().mockImplementation(() => ({
|
||||
prompt: vi.fn().mockImplementation(async function* () {
|
||||
yield { type: 'agent_start', value: { sessionId: 'test-session' } };
|
||||
yield {
|
||||
type: 'agent_finish',
|
||||
value: { sessionId: 'test-session', totalTurns: 1 },
|
||||
};
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('Agent', () => {
|
||||
let mockConfig: ReturnType<typeof makeFakeConfig>;
|
||||
const agentConfig: AgentConfig = {
|
||||
name: 'TestAgent',
|
||||
systemInstruction: 'You are a test agent.',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockConfig = makeFakeConfig();
|
||||
vi.spyOn(mockConfig, 'getSessionId').mockReturnValue('global-session-id');
|
||||
});
|
||||
|
||||
it('should create an AgentSession', () => {
|
||||
const agent = new Agent(agentConfig, mockConfig);
|
||||
const session = agent.createSession('custom-session-id');
|
||||
|
||||
expect(session).toBeDefined();
|
||||
expect(AgentSession).toHaveBeenCalledWith(
|
||||
'custom-session-id',
|
||||
agentConfig,
|
||||
mockConfig,
|
||||
);
|
||||
});
|
||||
|
||||
it('should use global session ID if none provided to createSession', () => {
|
||||
const agent = new Agent(agentConfig, mockConfig);
|
||||
agent.createSession();
|
||||
|
||||
expect(AgentSession).toHaveBeenCalledWith(
|
||||
'global-session-id',
|
||||
agentConfig,
|
||||
mockConfig,
|
||||
);
|
||||
});
|
||||
|
||||
it('should prompt through a new session', async () => {
|
||||
const agent = new Agent(agentConfig, mockConfig);
|
||||
const events = [];
|
||||
for await (const event of agent.prompt('Hello')) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0].type).toBe('agent_start');
|
||||
expect(events[1].type).toBe('agent_finish');
|
||||
expect(AgentSession).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type Part } from '@google/genai';
|
||||
import { type Config } from '../config/config.js';
|
||||
import { type AgentEvent, type AgentConfig } from './types.js';
|
||||
import { AgentSession } from './session.js';
|
||||
|
||||
/**
|
||||
* The Agent class is a factory for creating stateful AgentSessions.
|
||||
* This represents a configured agent template.
|
||||
*/
|
||||
export class Agent {
|
||||
constructor(
|
||||
private readonly config: AgentConfig,
|
||||
private readonly runtime: Config,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates a new stateful session for interacting with the agent.
|
||||
*/
|
||||
createSession(sessionId?: string): AgentSession {
|
||||
const id = sessionId ?? this.runtime.getSessionId();
|
||||
return new AgentSession(id, this.config, this.runtime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to quickly run a single prompt and get the results.
|
||||
*/
|
||||
async *prompt(
|
||||
input: string | Part[],
|
||||
sessionId?: string,
|
||||
signal?: AbortSignal,
|
||||
): AsyncIterable<AgentEvent> {
|
||||
const session = this.createSession(sessionId);
|
||||
yield* session.prompt(input, signal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { AgentSession } from './session.js';
|
||||
import { makeFakeConfig } from '../test-utils/config.js';
|
||||
import { type AgentConfig } from './types.js';
|
||||
import { Scheduler } from '../scheduler/scheduler.js';
|
||||
import { GeminiEventType } from '../core/turn.js';
|
||||
import { ChatCompressionService } from '../services/chatCompressionService.js';
|
||||
import { CompressionStatus } from '../core/turn.js';
|
||||
import { AgentTerminateMode, type AgentEvent } from './types.js';
|
||||
import { ToolErrorType } from '../tools/tool-error.js';
|
||||
|
||||
vi.mock('../core/client.js');
|
||||
vi.mock('../scheduler/scheduler.js');
|
||||
vi.mock('../services/chatCompressionService.js');
|
||||
|
||||
describe('AgentSession', () => {
|
||||
let mockConfig: ReturnType<typeof makeFakeConfig>;
|
||||
let mockClient: {
|
||||
sendMessageStream: ReturnType<typeof vi.fn>;
|
||||
getChat: ReturnType<typeof vi.fn>;
|
||||
getCurrentSequenceModel: ReturnType<typeof vi.fn>;
|
||||
getHistory: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockScheduler: {
|
||||
schedule: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockCompressionService: {
|
||||
compress: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let session: AgentSession;
|
||||
const agentConfig: AgentConfig = {
|
||||
name: 'TestAgent',
|
||||
capabilities: { compression: true },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockConfig = makeFakeConfig();
|
||||
|
||||
mockClient = {
|
||||
sendMessageStream: vi.fn(),
|
||||
getChat: vi.fn().mockReturnValue({
|
||||
recordCompletedToolCalls: vi.fn(),
|
||||
setHistory: vi.fn(),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
getCurrentSequenceModel: vi.fn().mockReturnValue('test-model'),
|
||||
getHistory: vi.fn().mockReturnValue([]),
|
||||
};
|
||||
|
||||
mockScheduler = {
|
||||
schedule: vi.fn(),
|
||||
};
|
||||
|
||||
mockCompressionService = {
|
||||
compress: vi.fn().mockResolvedValue({
|
||||
newHistory: null,
|
||||
info: { compressionStatus: CompressionStatus.NOOP },
|
||||
}),
|
||||
};
|
||||
|
||||
vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue(
|
||||
mockClient as unknown as import('../core/client.js').GeminiClient,
|
||||
);
|
||||
vi.mocked(Scheduler).mockImplementation(
|
||||
() => mockScheduler as unknown as Scheduler,
|
||||
);
|
||||
vi.mocked(ChatCompressionService).mockImplementation(
|
||||
() => mockCompressionService as unknown as ChatCompressionService,
|
||||
);
|
||||
|
||||
session = new AgentSession('test-session', agentConfig, mockConfig);
|
||||
});
|
||||
|
||||
it('should emit agent_start and agent_finish', async () => {
|
||||
mockClient.sendMessageStream.mockImplementation(async function* () {
|
||||
yield { type: GeminiEventType.Content, value: 'Hello' };
|
||||
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
|
||||
});
|
||||
|
||||
const events = [];
|
||||
for await (const event of session.prompt('Hi')) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
const finishEvent = events[events.length - 1] as Extract<
|
||||
AgentEvent,
|
||||
{ type: 'agent_finish' }
|
||||
>;
|
||||
expect(events[0].type).toBe('agent_start');
|
||||
expect(finishEvent.type).toBe('agent_finish');
|
||||
expect(finishEvent.value.reason).toBe(AgentTerminateMode.GOAL);
|
||||
expect(mockClient.sendMessageStream).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle tool calls and execute them', async () => {
|
||||
// Turn 1: Model calls a tool
|
||||
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
|
||||
yield {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: { callId: 'call1', name: 'test_tool', args: {} },
|
||||
};
|
||||
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
|
||||
});
|
||||
|
||||
// Turn 2: Model finishes
|
||||
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
|
||||
yield { type: GeminiEventType.Content, value: 'Tool executed' };
|
||||
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
|
||||
});
|
||||
|
||||
mockScheduler.schedule.mockResolvedValueOnce([
|
||||
{
|
||||
response: {
|
||||
callId: 'call1',
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'test_tool',
|
||||
response: { ok: true },
|
||||
id: 'call1',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const events = [];
|
||||
for await (const event of session.prompt('Run tool')) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
expect(mockClient.sendMessageStream).toHaveBeenCalledTimes(2);
|
||||
expect(mockScheduler.schedule).toHaveBeenCalledTimes(1);
|
||||
|
||||
const suiteStart = events.find((e) => e.type === 'tool_suite_start');
|
||||
const suiteFinish = events.find((e) => e.type === 'tool_suite_finish');
|
||||
expect(suiteStart).toBeDefined();
|
||||
expect(suiteFinish).toBeDefined();
|
||||
expect(suiteFinish?.value.responses[0].callId).toBe('call1');
|
||||
});
|
||||
|
||||
it('should trigger compression if enabled', async () => {
|
||||
mockClient.sendMessageStream.mockImplementation(async function* () {
|
||||
yield { type: GeminiEventType.Content, value: 'Done' };
|
||||
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
|
||||
});
|
||||
|
||||
for await (const _ of session.prompt('Compress me')) {
|
||||
// consume stream to trigger compression
|
||||
}
|
||||
|
||||
expect(mockCompressionService.compress).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should respect abort signal', async () => {
|
||||
const controller = new AbortController();
|
||||
mockClient.sendMessageStream.mockImplementation(async function* () {
|
||||
yield { type: GeminiEventType.Content, value: 'Thinking...' };
|
||||
controller.abort();
|
||||
yield { type: GeminiEventType.Content, value: 'Still thinking...' };
|
||||
});
|
||||
|
||||
const events = [];
|
||||
for await (const event of session.prompt('Long task', controller.signal)) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
// Should finish early
|
||||
const finishEvent = events[events.length - 1] as Extract<
|
||||
AgentEvent,
|
||||
{ type: 'agent_finish' }
|
||||
>;
|
||||
expect(finishEvent.type).toBe('agent_finish');
|
||||
expect(finishEvent.value.reason).toBe(AgentTerminateMode.ABORTED);
|
||||
// It might still yield the first chunk before the signal is processed in the loop
|
||||
});
|
||||
|
||||
it('should emit ERROR reason when a tool requests stop', async () => {
|
||||
// Turn 1: Model calls a tool
|
||||
mockClient.sendMessageStream.mockImplementationOnce(async function* () {
|
||||
yield {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: { callId: 'call_stop', name: 'stop_tool', args: {} },
|
||||
};
|
||||
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
|
||||
});
|
||||
|
||||
mockScheduler.schedule.mockResolvedValueOnce([
|
||||
{
|
||||
response: {
|
||||
callId: 'call_stop',
|
||||
errorType: ToolErrorType.STOP_EXECUTION,
|
||||
error: new Error('Deny listed command'),
|
||||
responseParts: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const events = [];
|
||||
for await (const event of session.prompt('Run tool')) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
const finishEvent = events.find(
|
||||
(e) => e.type === 'agent_finish',
|
||||
) as Extract<AgentEvent, { type: 'agent_finish' }>;
|
||||
expect(finishEvent).toBeDefined();
|
||||
expect(finishEvent.value.reason).toBe(AgentTerminateMode.ERROR);
|
||||
expect(finishEvent.value.message).toBe('Deny listed command');
|
||||
});
|
||||
|
||||
it('should respect maxTurns from config', async () => {
|
||||
const customSession = new AgentSession(
|
||||
'test-session-2',
|
||||
{ ...agentConfig, maxTurns: 2 },
|
||||
mockConfig,
|
||||
);
|
||||
|
||||
// Mock an infinite loop of tool calls from the model
|
||||
mockClient.sendMessageStream.mockImplementation(async function* () {
|
||||
yield {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
value: { callId: 'call', name: 'test_tool', args: {} },
|
||||
};
|
||||
yield { type: GeminiEventType.Finished, value: { reason: 'STOP' } };
|
||||
});
|
||||
|
||||
mockScheduler.schedule.mockResolvedValue([
|
||||
{
|
||||
response: {
|
||||
callId: 'call',
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'test_tool',
|
||||
response: { ok: true },
|
||||
id: 'call',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const events = [];
|
||||
for await (const event of customSession.prompt('Start loop')) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
// It should perform exactly 2 turns, meaning mockScheduler.schedule is called twice
|
||||
expect(mockScheduler.schedule).toHaveBeenCalledTimes(2);
|
||||
|
||||
// The last event should be agent_finish
|
||||
const finishEvent = events[events.length - 1] as Extract<
|
||||
AgentEvent,
|
||||
{ type: 'agent_finish' }
|
||||
>;
|
||||
expect(finishEvent.type).toBe('agent_finish');
|
||||
expect(finishEvent.value.totalTurns).toBe(2);
|
||||
expect(finishEvent.value.reason).toBe(AgentTerminateMode.MAX_TURNS);
|
||||
expect(finishEvent.value.message).toBe('Maximum session turns exceeded.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type Part, Type, type FunctionDeclaration, type Schema } from '@google/genai';
|
||||
import { type Config } from '../config/config.js';
|
||||
import { type GeminiClient } from '../core/client.js';
|
||||
import { type AgentEvent, type AgentConfig } from './types.js';
|
||||
import { Scheduler } from '../scheduler/scheduler.js';
|
||||
import {
|
||||
ROOT_SCHEDULER_ID,
|
||||
type ToolCallRequestInfo,
|
||||
type CompletedToolCall,
|
||||
CoreToolCallStatus,
|
||||
} from '../scheduler/types.js';
|
||||
import { GeminiEventType, CompressionStatus } from '../core/turn.js';
|
||||
import { recordToolCallInteractions } from '../code_assist/telemetry.js';
|
||||
import { debugLogger } from '../utils/debugLogger.js';
|
||||
import { ToolErrorType } from '../tools/tool-error.js';
|
||||
import { ChatCompressionService } from '../services/chatCompressionService.js';
|
||||
import { AgentTerminateMode } from './types.js';
|
||||
import type { ResumedSessionData } from '../services/chatRecordingService.js';
|
||||
import { convertSessionToClientHistory } from '../utils/sessionUtils.js';
|
||||
import { ToolRegistry } from '../tools/tool-registry.js';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import {
|
||||
type AnyDeclarativeTool,
|
||||
type AnyToolInvocation,
|
||||
} from '../tools/tools.js';
|
||||
|
||||
const TASK_COMPLETE_TOOL_NAME = 'complete_task';
|
||||
|
||||
/**
|
||||
* AgentSession manages the state of a conversation and orchestrates the agent
|
||||
* loop.
|
||||
*/
|
||||
export class AgentSession {
|
||||
private readonly client: GeminiClient;
|
||||
private readonly scheduler: Scheduler;
|
||||
private readonly toolRegistry: ToolRegistry;
|
||||
private readonly compressionService: ChatCompressionService;
|
||||
private totalTurns = 0;
|
||||
private hasFailedCompressionAttempt = false;
|
||||
|
||||
constructor(
|
||||
private readonly sessionId: string,
|
||||
private readonly config: AgentConfig,
|
||||
private readonly runtime: Config,
|
||||
) {
|
||||
// Initialize a scoped tool registry
|
||||
this.toolRegistry = new ToolRegistry(
|
||||
this.runtime,
|
||||
this.runtime.getMessageBus(),
|
||||
);
|
||||
this.setupToolRegistry();
|
||||
|
||||
// For now, we reuse the GeminiClient from the global config.
|
||||
this.client = this.runtime.getGeminiClient();
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
this.scheduler = new Scheduler({
|
||||
config: this.runtime,
|
||||
messageBus: this.runtime.getMessageBus(),
|
||||
getPreferredEditor: () => undefined,
|
||||
schedulerId: ROOT_SCHEDULER_ID,
|
||||
} as any);
|
||||
this.compressionService = new ChatCompressionService();
|
||||
}
|
||||
|
||||
private setupToolRegistry(): void {
|
||||
const parentRegistry = this.runtime.getToolRegistry();
|
||||
if (this.config.toolConfig) {
|
||||
for (const toolRef of this.config.toolConfig.tools) {
|
||||
if (typeof toolRef === 'string') {
|
||||
const tool = parentRegistry.getTool(toolRef);
|
||||
if (tool) {
|
||||
this.toolRegistry.registerTool(tool);
|
||||
}
|
||||
} else if (
|
||||
typeof toolRef === 'object' &&
|
||||
'name' in toolRef &&
|
||||
'build' in toolRef
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any
|
||||
this.toolRegistry.registerTool(
|
||||
toolRef as unknown as AnyDeclarativeTool,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If no tools specified, use all active tools from parent
|
||||
for (const tool of parentRegistry.getAllTools()) {
|
||||
this.toolRegistry.registerTool(tool);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getFunctionDeclarations(): FunctionDeclaration[] {
|
||||
const declarations = this.toolRegistry.getFunctionDeclarations();
|
||||
|
||||
// Add complete_task tool if outputConfig is provided
|
||||
if (this.config.outputConfig) {
|
||||
const jsonSchema = zodToJsonSchema(this.config.outputConfig.schema);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
|
||||
const { $schema, definitions, ...schema } = jsonSchema as any;
|
||||
|
||||
const completeTool: FunctionDeclaration = {
|
||||
name: TASK_COMPLETE_TOOL_NAME,
|
||||
description:
|
||||
this.config.outputConfig.description ||
|
||||
'Call this tool to submit your final answer and complete the task.',
|
||||
parameters: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
[this.config.outputConfig.outputName]: schema as Schema,
|
||||
},
|
||||
required: [this.config.outputConfig.outputName],
|
||||
},
|
||||
};
|
||||
declarations.push(completeTool);
|
||||
}
|
||||
|
||||
return declarations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes the agent session from persistent storage data.
|
||||
* Hydrates the internal language model client with the previously saved trajectory.
|
||||
*
|
||||
* @param resumedSessionData The raw payload of a previously saved session.
|
||||
*/
|
||||
async resume(resumedSessionData: ResumedSessionData): Promise<void> {
|
||||
const clientHistory = convertSessionToClientHistory(
|
||||
resumedSessionData.conversation.messages,
|
||||
);
|
||||
await this.client.resumeChat(clientHistory, resumedSessionData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the ReAct loop for a given user input.
|
||||
* Returns an AsyncIterable of events occurring during the session.
|
||||
*/
|
||||
async *prompt(
|
||||
input: string | Part[],
|
||||
signal?: AbortSignal,
|
||||
): AsyncIterable<AgentEvent> {
|
||||
yield {
|
||||
type: 'agent_start',
|
||||
value: { sessionId: this.sessionId },
|
||||
};
|
||||
|
||||
let currentInput = input;
|
||||
let isContinuation = false;
|
||||
const maxTurns = this.config.maxTurns ?? -1;
|
||||
|
||||
let terminationReason = AgentTerminateMode.GOAL;
|
||||
let terminationMessage: string | undefined = undefined;
|
||||
let terminationError: unknown | undefined = undefined;
|
||||
let finalResult: unknown | undefined = undefined;
|
||||
|
||||
try {
|
||||
while (maxTurns === -1 || this.totalTurns < maxTurns) {
|
||||
if (signal?.aborted) {
|
||||
terminationReason = AgentTerminateMode.ABORTED;
|
||||
break;
|
||||
}
|
||||
|
||||
this.totalTurns++;
|
||||
const promptId = `${this.sessionId}#${this.totalTurns}`;
|
||||
|
||||
// Update tools on the client so sendMessageStream sees them
|
||||
await this.client.setTools(this.config.model);
|
||||
|
||||
// Compression check (from LocalAgentExecutor / useGeminiStream patterns)
|
||||
if (this.config.capabilities?.compression) {
|
||||
await this.tryCompressChat(promptId);
|
||||
}
|
||||
|
||||
const { toolCalls, events } = await this.runModelTurn(
|
||||
currentInput,
|
||||
promptId,
|
||||
isContinuation ? undefined : input,
|
||||
combinedSignal,
|
||||
);
|
||||
|
||||
|
||||
for await (const event of events) {
|
||||
yield event;
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
terminationReason = AgentTerminateMode.ABORTED;
|
||||
break;
|
||||
}
|
||||
|
||||
if (toolCalls.length > 0) {
|
||||
// Check for complete_task call
|
||||
const completeTaskCall = toolCalls.find(
|
||||
(tc) => tc.name === TASK_COMPLETE_TOOL_NAME,
|
||||
);
|
||||
if (completeTaskCall && this.config.outputConfig) {
|
||||
const outputName = this.config.outputConfig.outputName;
|
||||
const result = completeTaskCall.args[outputName];
|
||||
|
||||
// Validate result
|
||||
const validation = this.config.outputConfig.schema.safeParse(result);
|
||||
if (validation.success) {
|
||||
finalResult = validation.data;
|
||||
yield {
|
||||
type: 'goal_completed',
|
||||
value: { result: finalResult },
|
||||
};
|
||||
|
||||
// Manually create a success response for complete_task to satisfy history
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any
|
||||
const response = {
|
||||
status: CoreToolCallStatus.Success,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any
|
||||
tool: undefined as unknown as AnyDeclarativeTool as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any
|
||||
invocation: undefined as unknown as AnyToolInvocation as any,
|
||||
response: {
|
||||
callId: completeTaskCall.callId,
|
||||
responseParts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: completeTaskCall.callId,
|
||||
name: TASK_COMPLETE_TOOL_NAME,
|
||||
response: { result: 'Task completed successfully.' },
|
||||
},
|
||||
},
|
||||
],
|
||||
resultDisplay: 'Task completed successfully.',
|
||||
error: undefined,
|
||||
errorType: undefined,
|
||||
contentLength: 0,
|
||||
},
|
||||
durationMs: 0,
|
||||
schedulerId: ROOT_SCHEDULER_ID,
|
||||
} as unknown as CompletedToolCall;
|
||||
|
||||
// Add to history so model knows it finished
|
||||
await this.client.addHistory({
|
||||
role: 'user',
|
||||
parts: response.response.responseParts,
|
||||
});
|
||||
|
||||
terminationReason = AgentTerminateMode.GOAL;
|
||||
break;
|
||||
} else {
|
||||
// Yield error and continue (model needs to fix output)
|
||||
const errorMsg = `Output validation failed: ${JSON.stringify(validation.error.flatten())}`;
|
||||
const errorParts: Part[] = [
|
||||
{
|
||||
functionResponse: {
|
||||
id: completeTaskCall.callId,
|
||||
name: TASK_COMPLETE_TOOL_NAME,
|
||||
response: { error: errorMsg },
|
||||
},
|
||||
},
|
||||
];
|
||||
await this.client.addHistory({
|
||||
role: 'user',
|
||||
parts: errorParts,
|
||||
});
|
||||
currentInput = errorParts;
|
||||
isContinuation = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const results = await this.executeTools(toolCalls, signal);
|
||||
for await (const event of results.events) {
|
||||
yield event;
|
||||
}
|
||||
|
||||
if (results.stopExecution || signal?.aborted) {
|
||||
if (signal?.aborted) {
|
||||
terminationReason = AgentTerminateMode.ABORTED;
|
||||
} else if (results.stopExecutionInfo) {
|
||||
terminationReason = AgentTerminateMode.ERROR;
|
||||
terminationMessage = results.stopExecutionInfo.error?.message;
|
||||
terminationError = results.stopExecutionInfo.error;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if we hit the turn limit
|
||||
if (maxTurns !== -1 && this.totalTurns >= maxTurns) {
|
||||
terminationReason = AgentTerminateMode.MAX_TURNS;
|
||||
terminationMessage = 'Maximum session turns exceeded.';
|
||||
break;
|
||||
}
|
||||
|
||||
currentInput = results.nextParts;
|
||||
isContinuation = true;
|
||||
} else {
|
||||
// No more tool calls, turn is complete.
|
||||
// If we completed naturally but were at the limit, it's still a GOAL
|
||||
terminationReason = AgentTerminateMode.GOAL;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
yield {
|
||||
type: 'agent_finish',
|
||||
value: {
|
||||
sessionId: this.sessionId,
|
||||
totalTurns: this.totalTurns,
|
||||
reason: terminationReason,
|
||||
message: terminationMessage,
|
||||
error: terminationError,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the model and yields the event stream.
|
||||
* Collects tool call requests for the next phase.
|
||||
*/
|
||||
private async runModelTurn(
|
||||
input: string | Part[],
|
||||
promptId: string,
|
||||
displayContent?: string | Part[],
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const parts = Array.isArray(input) ? input : [{ text: input }];
|
||||
const toolCalls: ToolCallRequestInfo[] = [];
|
||||
|
||||
const stream = this.client.sendMessageStream(
|
||||
parts,
|
||||
signal ?? new AbortController().signal,
|
||||
promptId,
|
||||
undefined, // maxTurns (client handles its own)
|
||||
false, // isInvalidStreamRetry
|
||||
displayContent,
|
||||
);
|
||||
|
||||
const eventGenerator = async function* (): AsyncIterable<AgentEvent> {
|
||||
for await (const event of stream) {
|
||||
if (event.type === GeminiEventType.ToolCallRequest) {
|
||||
toolCalls.push(event.value);
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
yield event as unknown as AgentEvent;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
toolCalls,
|
||||
events: eventGenerator(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a batch of tool calls via the Scheduler.
|
||||
*/
|
||||
private async executeTools(
|
||||
toolCalls: ToolCallRequestInfo[],
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const events: AgentEvent[] = [];
|
||||
events.push({
|
||||
type: 'tool_suite_start',
|
||||
value: { count: toolCalls.length },
|
||||
});
|
||||
|
||||
// We need to use our scoped tool registry.
|
||||
// However, the current Scheduler doesn't take a ToolRegistry in its constructor.
|
||||
// It uses the global registry from Config.
|
||||
// To implement scoping correctly without changing Scheduler, we might need a ScopedConfig.
|
||||
// For now, let's assume we can pass it or that we'll refactor Scheduler later.
|
||||
// As a workaround, we'll manually execute tools or rely on the global registry if scoping is not yet strictly enforced.
|
||||
// TODO: Support scoped ToolRegistry in Scheduler.
|
||||
|
||||
const completedCalls = await this.scheduler.schedule(
|
||||
toolCalls,
|
||||
signal ?? new AbortController().signal,
|
||||
);
|
||||
|
||||
events.push({
|
||||
type: 'tool_suite_finish',
|
||||
value: { responses: completedCalls.map((c) => c.response) },
|
||||
});
|
||||
|
||||
// Record tool call info for persistence/telemetry
|
||||
try {
|
||||
const currentModel =
|
||||
this.client.getCurrentSequenceModel() ?? this.runtime.getModel();
|
||||
this.client
|
||||
.getChat()
|
||||
.recordCompletedToolCalls(currentModel, completedCalls);
|
||||
await recordToolCallInteractions(this.runtime, completedCalls);
|
||||
} catch (e) {
|
||||
debugLogger.warn(`Error recording tool call information: ${e}`);
|
||||
}
|
||||
|
||||
const nextParts = completedCalls.flatMap((c) => c.response.responseParts);
|
||||
const stopExecutionInfo = completedCalls.find(
|
||||
(c) => c.response.errorType === ToolErrorType.STOP_EXECUTION,
|
||||
)?.response;
|
||||
|
||||
const eventGenerator = async function* () {
|
||||
for (const event of events) {
|
||||
yield event;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
nextParts,
|
||||
stopExecution: !!stopExecutionInfo,
|
||||
stopExecutionInfo,
|
||||
events: eventGenerator(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to compress the chat history if thresholds are exceeded.
|
||||
*/
|
||||
private async tryCompressChat(promptId: string): Promise<void> {
|
||||
const chat = this.client.getChat();
|
||||
const model = this.config.model ?? this.runtime.getModel();
|
||||
|
||||
const { newHistory, info } = await this.compressionService.compress(
|
||||
chat,
|
||||
promptId,
|
||||
false,
|
||||
model,
|
||||
this.runtime,
|
||||
this.hasFailedCompressionAttempt,
|
||||
);
|
||||
|
||||
if (
|
||||
info.compressionStatus ===
|
||||
CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT
|
||||
) {
|
||||
this.hasFailedCompressionAttempt = true;
|
||||
} else if (info.compressionStatus === CompressionStatus.COMPRESSED) {
|
||||
if (newHistory) {
|
||||
chat.setHistory(newHistory);
|
||||
this.hasFailedCompressionAttempt = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current message history for this session.
|
||||
*/
|
||||
getHistory() {
|
||||
return this.client.getHistory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current session ID.
|
||||
*/
|
||||
getSessionId(): string {
|
||||
return this.sessionId;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,75 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Defines the core configuration interfaces and types for the agent architecture.
|
||||
*/
|
||||
|
||||
import type { Content, FunctionDeclaration } from '@google/genai';
|
||||
import type { AnyDeclarativeTool } from '../tools/tools.js';
|
||||
import { type z } from 'zod';
|
||||
import type { ModelConfig } from '../services/modelConfigService.js';
|
||||
import type { AnySchema } from 'ajv';
|
||||
import type { A2AAuthConfig } from './auth-provider/types.js';
|
||||
import { type ServerGeminiStreamEvent } from '../core/turn.js';
|
||||
import { type ToolCallResponseInfo } from '../scheduler/types.js';
|
||||
|
||||
/**
|
||||
* Unified event type for the Agent loop.
|
||||
* This extends the base Gemini stream events with higher-level agent lifecycle events.
|
||||
*/
|
||||
export type AgentEvent =
|
||||
| ServerGeminiStreamEvent
|
||||
| { type: 'agent_start'; value: { sessionId: string } }
|
||||
| {
|
||||
type: 'agent_finish';
|
||||
value: {
|
||||
sessionId: string;
|
||||
totalTurns: number;
|
||||
reason: AgentTerminateMode;
|
||||
message?: string;
|
||||
error?: unknown;
|
||||
};
|
||||
}
|
||||
| { type: 'tool_suite_start'; value: { count: number } }
|
||||
| { type: 'tool_suite_finish'; value: { responses: ToolCallResponseInfo[] } }
|
||||
| { type: 'thought'; value: string }
|
||||
| { type: 'loop_detected'; value: { sessionId: string } }
|
||||
| { type: 'goal_completed'; value: { result: unknown } };
|
||||
|
||||
/**
|
||||
* Configuration for an Agent.
|
||||
*/
|
||||
export interface AgentConfig {
|
||||
/** The name of the agent. */
|
||||
name: string;
|
||||
/** The system instruction (personality/rules) for the agent. */
|
||||
systemInstruction?: string;
|
||||
/** Optional override for the model to use. */
|
||||
model?: string;
|
||||
/**
|
||||
* Optional maximum number of conversational turns.
|
||||
* Set to -1 for no limit, defaults to -1 if not specified.
|
||||
*/
|
||||
maxTurns?: number;
|
||||
/**
|
||||
* Optional capabilities to enable for this agent.
|
||||
*/
|
||||
capabilities?: {
|
||||
compression?: boolean;
|
||||
loopDetection?: boolean;
|
||||
ideContext?: boolean;
|
||||
};
|
||||
/**
|
||||
* Optional tools available to the agent.
|
||||
* If not specified, the agent uses all tools registered in the runtime.
|
||||
*/
|
||||
toolConfig?: ToolConfig;
|
||||
/**
|
||||
* Optional configuration for the expected structured output.
|
||||
* If specified, the agent will be provided with a `complete_task` tool.
|
||||
*/
|
||||
outputConfig?: OutputConfig<z.ZodTypeAny>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the possible termination modes for an agent.
|
||||
|
||||
@@ -502,6 +502,10 @@ export interface ConfigParameters {
|
||||
plan?: boolean;
|
||||
planSettings?: PlanSettings;
|
||||
modelSteering?: boolean;
|
||||
useAgentFactoryAll?: boolean;
|
||||
useAgentFactorySdk?: boolean;
|
||||
useAgentFactoryNonInteractive?: boolean;
|
||||
useAgentFactoryInteractive?: boolean;
|
||||
onModelChange?: (model: string) => void;
|
||||
mcpEnabled?: boolean;
|
||||
extensionsEnabled?: boolean;
|
||||
@@ -703,6 +707,11 @@ export class Config {
|
||||
readonly userHintService: UserHintService;
|
||||
private approvedPlanPath: string | undefined;
|
||||
|
||||
private readonly useAgentFactoryAll: boolean;
|
||||
private readonly useAgentFactorySdk: boolean;
|
||||
private readonly useAgentFactoryNonInteractive: boolean;
|
||||
private readonly useAgentFactoryInteractive: boolean;
|
||||
|
||||
constructor(params: ConfigParameters) {
|
||||
this.sessionId = params.sessionId;
|
||||
this.clientVersion = params.clientVersion ?? 'unknown';
|
||||
@@ -790,6 +799,12 @@ export class Config {
|
||||
this.modelAvailabilityService = new ModelAvailabilityService();
|
||||
this.experimentalJitContext = params.experimentalJitContext ?? false;
|
||||
this.modelSteering = params.modelSteering ?? false;
|
||||
this.useAgentFactoryAll = params.useAgentFactoryAll ?? false;
|
||||
this.useAgentFactorySdk = params.useAgentFactorySdk ?? false;
|
||||
this.useAgentFactoryNonInteractive =
|
||||
params.useAgentFactoryNonInteractive ?? false;
|
||||
this.useAgentFactoryInteractive =
|
||||
params.useAgentFactoryInteractive ?? false;
|
||||
this.userHintService = new UserHintService(() =>
|
||||
this.isModelSteeringEnabled(),
|
||||
);
|
||||
@@ -1558,6 +1573,27 @@ export class Config {
|
||||
*
|
||||
* May change over time.
|
||||
*/
|
||||
getExperimentalSetting(
|
||||
key:
|
||||
| 'useAgentFactoryAll'
|
||||
| 'useAgentFactorySdk'
|
||||
| 'useAgentFactoryNonInteractive'
|
||||
| 'useAgentFactoryInteractive',
|
||||
): boolean {
|
||||
switch (key) {
|
||||
case 'useAgentFactoryAll':
|
||||
return this.useAgentFactoryAll;
|
||||
case 'useAgentFactorySdk':
|
||||
return this.useAgentFactorySdk;
|
||||
case 'useAgentFactoryNonInteractive':
|
||||
return this.useAgentFactoryNonInteractive;
|
||||
case 'useAgentFactoryInteractive':
|
||||
return this.useAgentFactoryInteractive;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getExcludeTools(): Set<string> | undefined {
|
||||
// Right now this is present for backward compatibility with settings.json exclude
|
||||
const excludeToolsSet = new Set([...(this.excludeTools ?? [])]);
|
||||
|
||||
@@ -107,6 +107,7 @@ export * from './utils/secure-browser-launcher.js';
|
||||
export * from './utils/apiConversionUtils.js';
|
||||
export * from './utils/channel.js';
|
||||
export * from './utils/constants.js';
|
||||
export * from './utils/sessionUtils.js';
|
||||
|
||||
// Export services
|
||||
export * from './services/fileDiscoveryService.js';
|
||||
@@ -145,6 +146,7 @@ export * from './agents/types.js';
|
||||
export * from './agents/agentLoader.js';
|
||||
export * from './agents/local-executor.js';
|
||||
export * from './agents/agent-scheduler.js';
|
||||
export * from './agents/session.js';
|
||||
|
||||
// Export specific tool logic
|
||||
export * from './tools/read-file.js';
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { convertSessionToClientHistory } from './sessionUtils.js';
|
||||
import { type ConversationRecord } from '../services/chatRecordingService.js';
|
||||
import { CoreToolCallStatus } from '../scheduler/types.js';
|
||||
|
||||
describe('convertSessionToClientHistory', () => {
|
||||
it('should convert a simple conversation without tool calls', () => {
|
||||
const messages: ConversationRecord['messages'] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'user',
|
||||
timestamp: '2024-01-01T10:00:00Z',
|
||||
content: 'Hello',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'gemini',
|
||||
timestamp: '2024-01-01T10:01:00Z',
|
||||
content: 'Hi there',
|
||||
},
|
||||
];
|
||||
|
||||
const history = convertSessionToClientHistory(messages);
|
||||
|
||||
expect(history).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'Hello' }] },
|
||||
{ role: 'model', parts: [{ text: 'Hi there' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should ignore info, error, and slash commands', () => {
|
||||
const messages: ConversationRecord['messages'] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'info',
|
||||
timestamp: '2024-01-01T10:00:00Z',
|
||||
content: 'System info',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'user',
|
||||
timestamp: '2024-01-01T10:01:00Z',
|
||||
content: '/clear',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'user',
|
||||
timestamp: '2024-01-01T10:02:00Z',
|
||||
content: '?help',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'user',
|
||||
timestamp: '2024-01-01T10:03:00Z',
|
||||
content: 'Actual query',
|
||||
},
|
||||
];
|
||||
|
||||
const history = convertSessionToClientHistory(messages);
|
||||
|
||||
expect(history).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'Actual query' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should correct map tool calls and their responses', () => {
|
||||
const messages: ConversationRecord['messages'] = [
|
||||
{
|
||||
id: 'msg1',
|
||||
type: 'user',
|
||||
timestamp: '2024-01-01T10:00:00Z',
|
||||
content: 'List files',
|
||||
},
|
||||
{
|
||||
id: 'msg2',
|
||||
type: 'gemini',
|
||||
timestamp: '2024-01-01T10:01:00Z',
|
||||
content: 'Let me check.',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call123',
|
||||
name: 'ls',
|
||||
args: { dir: '.' },
|
||||
status: CoreToolCallStatus.Success,
|
||||
timestamp: '2024-01-01T10:01:05Z',
|
||||
result: 'file.txt',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const history = convertSessionToClientHistory(messages);
|
||||
|
||||
expect(history).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'List files' }] },
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'Let me check.' },
|
||||
{ functionCall: { name: 'ls', args: { dir: '.' }, id: 'call123' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call123',
|
||||
name: 'ls',
|
||||
response: { output: 'file.txt' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type Part } from '@google/genai';
|
||||
import { type ConversationRecord } from '../services/chatRecordingService.js';
|
||||
import { partListUnionToString } from '../core/geminiRequest.js';
|
||||
|
||||
/**
|
||||
* Converts session/conversation data into Gemini client history formats.
|
||||
*/
|
||||
export function convertSessionToClientHistory(
|
||||
messages: ConversationRecord['messages'],
|
||||
): Array<{ role: 'user' | 'model'; parts: Part[] }> {
|
||||
const clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }> = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.type === 'info' || msg.type === 'error' || msg.type === 'warning') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.type === 'user') {
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
if (
|
||||
contentString.trim().startsWith('/') ||
|
||||
contentString.trim().startsWith('?')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
clientHistory.push({
|
||||
role: 'user',
|
||||
parts: Array.isArray(msg.content)
|
||||
? // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(msg.content as Part[])
|
||||
: [{ text: contentString }],
|
||||
});
|
||||
} else if (msg.type === 'gemini') {
|
||||
const hasToolCalls = msg.toolCalls && msg.toolCalls.length > 0;
|
||||
|
||||
if (hasToolCalls) {
|
||||
const modelParts: Part[] = [];
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
if (msg.content && contentString.trim()) {
|
||||
modelParts.push({ text: contentString });
|
||||
}
|
||||
|
||||
for (const toolCall of msg.toolCalls!) {
|
||||
modelParts.push({
|
||||
functionCall: {
|
||||
name: toolCall.name,
|
||||
args: toolCall.args,
|
||||
...(toolCall.id && { id: toolCall.id }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
clientHistory.push({
|
||||
role: 'model',
|
||||
parts: modelParts,
|
||||
});
|
||||
|
||||
const functionResponseParts: Part[] = [];
|
||||
for (const toolCall of msg.toolCalls!) {
|
||||
if (toolCall.result) {
|
||||
let responseData: Part;
|
||||
|
||||
if (typeof toolCall.result === 'string') {
|
||||
responseData = {
|
||||
functionResponse: {
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
response: {
|
||||
output: toolCall.result,
|
||||
},
|
||||
},
|
||||
};
|
||||
} else if (Array.isArray(toolCall.result)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
functionResponseParts.push(...(toolCall.result as Part[]));
|
||||
continue;
|
||||
} else {
|
||||
responseData = toolCall.result;
|
||||
}
|
||||
|
||||
functionResponseParts.push(responseData);
|
||||
}
|
||||
}
|
||||
|
||||
if (functionResponseParts.length > 0) {
|
||||
clientHistory.push({
|
||||
role: 'user',
|
||||
parts: functionResponseParts,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const contentString = partListUnionToString(msg.content);
|
||||
if (msg.content && contentString.trim()) {
|
||||
clientHistory.push({
|
||||
role: 'model',
|
||||
parts: [{ text: contentString }],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return clientHistory;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
createSessionId,
|
||||
type ResumedSessionData,
|
||||
type ConversationRecord,
|
||||
type ServerGeminiStreamEvent,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import { GeminiCliSession } from './session.js';
|
||||
@@ -22,11 +23,23 @@ export class GeminiCliAgent {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new session.
|
||||
*
|
||||
* @param options Session options.
|
||||
* @returns A new GeminiCliSession instance.
|
||||
*/
|
||||
session(options?: { sessionId?: string }): GeminiCliSession {
|
||||
const sessionId = options?.sessionId || createSessionId();
|
||||
return new GeminiCliSession(this.options, sessionId, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes a session by ID.
|
||||
*
|
||||
* @param sessionId The ID of the session to resume.
|
||||
* @returns A GeminiCliSession instance hydrated with session history.
|
||||
*/
|
||||
async resumeSession(sessionId: string): Promise<GeminiCliSession> {
|
||||
const cwd = this.options.cwd || process.cwd();
|
||||
const storage = new Storage(cwd);
|
||||
@@ -45,13 +58,7 @@ export class GeminiCliAgent {
|
||||
|
||||
const truncatedId = sessionId.slice(0, 8);
|
||||
// Optimization: filenames include first 8 chars of sessionId.
|
||||
// Filter sessions that might match.
|
||||
const candidates = sessions.filter((s) => s.filePath.includes(truncatedId));
|
||||
|
||||
// If optimization fails (e.g. old files), check all?
|
||||
// Assuming filenames always follow convention if created by this tool.
|
||||
// But we can fallback to checking all if needed, but let's stick to candidates first.
|
||||
// If candidates is empty, maybe fallback to all.
|
||||
const filesToCheck = candidates.length > 0 ? candidates : sessions;
|
||||
|
||||
for (const sessionFile of filesToCheck) {
|
||||
@@ -74,11 +81,25 @@ export class GeminiCliAgent {
|
||||
filePath,
|
||||
};
|
||||
|
||||
return new GeminiCliSession(
|
||||
const session = new GeminiCliSession(
|
||||
this.options,
|
||||
conversation.sessionId,
|
||||
this,
|
||||
resumedData,
|
||||
);
|
||||
await session.initialize();
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper for non-session-managed streams.
|
||||
* @deprecated Use agent.session().sendStream() instead.
|
||||
*/
|
||||
async *sendStream(
|
||||
prompt: string,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<ServerGeminiStreamEvent> {
|
||||
const session = this.session();
|
||||
yield* session.sendStream(prompt, signal);
|
||||
}
|
||||
}
|
||||
|
||||
+71
-17
@@ -21,6 +21,10 @@ import {
|
||||
ActivateSkillTool,
|
||||
type ResumedSessionData,
|
||||
PolicyDecision,
|
||||
AgentSession,
|
||||
type AgentConfig,
|
||||
Scheduler,
|
||||
ROOT_SCHEDULER_ID,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import { type Tool, SdkTool } from './tool.js';
|
||||
@@ -42,6 +46,7 @@ export class GeminiCliSession {
|
||||
private readonly instructions: SystemInstructions | undefined;
|
||||
private client: GeminiClient | undefined;
|
||||
private initialized = false;
|
||||
private agentSession: AgentSession | undefined;
|
||||
|
||||
constructor(
|
||||
options: GeminiCliAgentOptions,
|
||||
@@ -146,25 +151,35 @@ export class GeminiCliSession {
|
||||
this.client = this.config.getGeminiClient();
|
||||
|
||||
if (this.resumedData) {
|
||||
const history: Content[] = this.resumedData.conversation.messages.map(
|
||||
(m) => {
|
||||
const role = m.type === 'gemini' ? 'model' : 'user';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let parts: any[] = [];
|
||||
if (Array.isArray(m.content)) {
|
||||
parts = m.content;
|
||||
} else if (m.content) {
|
||||
parts = [{ text: String(m.content) }];
|
||||
}
|
||||
return { role, parts };
|
||||
},
|
||||
);
|
||||
await this.client.resumeChat(history, this.resumedData);
|
||||
// The AgentSession's resume method is cleaner
|
||||
// but for now we follow the existing pattern if needed
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.getAgentSession().resume(this.resumedData);
|
||||
}
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
private getAgentSession(): AgentSession {
|
||||
if (!this.agentSession) {
|
||||
const agentConfig: AgentConfig = {
|
||||
name: 'sdk-agent',
|
||||
systemInstruction: this.config.getUserMemory(),
|
||||
model: this.config.getModel(),
|
||||
capabilities: {
|
||||
compression: true,
|
||||
loopDetection: true,
|
||||
},
|
||||
};
|
||||
this.agentSession = new AgentSession(
|
||||
this.sessionId,
|
||||
agentConfig,
|
||||
this.config,
|
||||
);
|
||||
}
|
||||
return this.agentSession;
|
||||
}
|
||||
|
||||
async *sendStream(
|
||||
prompt: string,
|
||||
signal?: AbortSignal,
|
||||
@@ -176,6 +191,46 @@ export class GeminiCliSession {
|
||||
const abortSignal = signal ?? new AbortController().signal;
|
||||
const sessionId = this.config.getSessionId();
|
||||
|
||||
const useAgentFactory =
|
||||
this.config.getExperimentalSetting('useAgentFactoryAll') ||
|
||||
this.config.getExperimentalSetting('useAgentFactorySdk');
|
||||
|
||||
if (useAgentFactory) {
|
||||
// Dynamic instructions check before starting
|
||||
if (typeof this.instructions === 'function') {
|
||||
const fs = new SdkAgentFilesystem(this.config);
|
||||
const shell = new SdkAgentShell(this.config);
|
||||
const context: SessionContext = {
|
||||
sessionId,
|
||||
transcript: client.getHistory(),
|
||||
cwd: this.config.getWorkingDir(),
|
||||
timestamp: new Date().toISOString(),
|
||||
fs,
|
||||
shell,
|
||||
agent: this.agent,
|
||||
session: this,
|
||||
};
|
||||
const newInstructions = await this.instructions(context);
|
||||
this.config.setUserMemory(newInstructions);
|
||||
client.updateSystemInstruction();
|
||||
}
|
||||
|
||||
const stream = this.getAgentSession().prompt(prompt, abortSignal);
|
||||
for await (const event of stream) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
yield event as unknown as ServerGeminiStreamEvent;
|
||||
}
|
||||
} else {
|
||||
yield* this.legacySendStream(prompt, abortSignal);
|
||||
}
|
||||
}
|
||||
|
||||
private async *legacySendStream(
|
||||
prompt: string,
|
||||
signal: AbortSignal,
|
||||
): AsyncGenerator<ServerGeminiStreamEvent> {
|
||||
const client = this.client!;
|
||||
const sessionId = this.sessionId;
|
||||
const fs = new SdkAgentFilesystem(this.config);
|
||||
const shell = new SdkAgentShell(this.config);
|
||||
|
||||
@@ -200,8 +255,7 @@ export class GeminiCliSession {
|
||||
client.updateSystemInstruction();
|
||||
}
|
||||
|
||||
const stream = client.sendMessageStream(request, abortSignal, sessionId);
|
||||
|
||||
const stream = client.sendMessageStream(request, signal, sessionId);
|
||||
const toolCallsToSchedule: ToolCallRequestInfo[] = [];
|
||||
|
||||
for await (const event of stream) {
|
||||
@@ -255,7 +309,7 @@ export class GeminiCliSession {
|
||||
{
|
||||
schedulerId: sessionId,
|
||||
toolRegistry: scopedRegistry,
|
||||
signal: abortSignal,
|
||||
signal,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -507,6 +507,44 @@
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"experimental": {
|
||||
"title": "Experimental",
|
||||
"description": "Experimental features and capabilities.",
|
||||
"markdownDescription": "Experimental features and capabilities.\n\n- Category: `Advanced`\n- Requires restart: `yes`\n- Default: `{}`",
|
||||
"default": {},
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"useAgentFactoryAll": {
|
||||
"title": "Use Agent Factory (All)",
|
||||
"description": "Enable Agent Factory for all supported execution paths.",
|
||||
"markdownDescription": "Enable Agent Factory for all supported execution paths.\n\n- Category: `Advanced`\n- Requires restart: `yes`\n- Default: `false`",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"useAgentFactorySdk": {
|
||||
"title": "Use Agent Factory (SDK)",
|
||||
"description": "Enable Agent Factory for the SDK execution path.",
|
||||
"markdownDescription": "Enable Agent Factory for the SDK execution path.\n\n- Category: `Advanced`\n- Requires restart: `yes`\n- Default: `false`",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"useAgentFactoryNonInteractive": {
|
||||
"title": "Use Agent Factory (Non-Interactive)",
|
||||
"description": "Enable Agent Factory for the non-interactive CLI execution path.",
|
||||
"markdownDescription": "Enable Agent Factory for the non-interactive CLI execution path.\n\n- Category: `Advanced`\n- Requires restart: `yes`\n- Default: `false`",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"useAgentFactoryInteractive": {
|
||||
"title": "Use Agent Factory (Interactive)",
|
||||
"description": "Enable Agent Factory for the interactive CLI execution path.",
|
||||
"markdownDescription": "Enable Agent Factory for the interactive CLI execution path.\n\n- Category: `Advanced`\n- Requires restart: `yes`\n- Default: `false`",
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"telemetry": {
|
||||
"title": "Telemetry",
|
||||
"description": "Telemetry configuration.",
|
||||
|
||||
Reference in New Issue
Block a user