mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-12 11:00:51 -07:00
429 lines
12 KiB
TypeScript
429 lines
12 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import type {
|
|
PartListUnion,
|
|
GenerateContentResponse,
|
|
FunctionCall,
|
|
FunctionDeclaration,
|
|
FinishReason,
|
|
GenerateContentResponseUsageMetadata,
|
|
} from '@google/genai';
|
|
import type {
|
|
ToolCallConfirmationDetails,
|
|
ToolResult,
|
|
} from '../tools/tools.js';
|
|
import { getResponseText } from '../utils/partUtils.js';
|
|
import { reportError } from '../utils/errorReporting.js';
|
|
import {
|
|
getErrorMessage,
|
|
UnauthorizedError,
|
|
toFriendlyError,
|
|
} from '../utils/errors.js';
|
|
import type { GeminiChat } from './geminiChat.js';
|
|
import { InvalidStreamError } from './geminiChat.js';
|
|
import { parseThought, type ThoughtSummary } from '../utils/thoughtUtils.js';
|
|
import { createUserContent } from '@google/genai';
|
|
import type { ModelConfigKey } from '../services/modelConfigService.js';
|
|
import { getCitations } from '../utils/generateContentResponseUtilities.js';
|
|
|
|
import {
|
|
type ToolCallRequestInfo,
|
|
type ToolCallResponseInfo,
|
|
} from '../scheduler/types.js';
|
|
|
|
export interface ServerTool {
|
|
name: string;
|
|
schema: FunctionDeclaration;
|
|
// The execute method signature might differ slightly or be wrapped
|
|
execute(
|
|
params: Record<string, unknown>,
|
|
signal?: AbortSignal,
|
|
): Promise<ToolResult>;
|
|
shouldConfirmExecute(
|
|
params: Record<string, unknown>,
|
|
abortSignal: AbortSignal,
|
|
): Promise<ToolCallConfirmationDetails | false>;
|
|
}
|
|
|
|
export enum GeminiEventType {
|
|
Content = 'content',
|
|
ToolCallRequest = 'tool_call_request',
|
|
ToolCallResponse = 'tool_call_response',
|
|
ToolCallConfirmation = 'tool_call_confirmation',
|
|
UserCancelled = 'user_cancelled',
|
|
Error = 'error',
|
|
ChatCompressed = 'chat_compressed',
|
|
Thought = 'thought',
|
|
MaxSessionTurns = 'max_session_turns',
|
|
Finished = 'finished',
|
|
LoopDetected = 'loop_detected',
|
|
Citation = 'citation',
|
|
Retry = 'retry',
|
|
ContextWindowWillOverflow = 'context_window_will_overflow',
|
|
InvalidStream = 'invalid_stream',
|
|
ModelInfo = 'model_info',
|
|
AgentExecutionStopped = 'agent_execution_stopped',
|
|
AgentExecutionBlocked = 'agent_execution_blocked',
|
|
}
|
|
|
|
export type ServerGeminiRetryEvent = {
|
|
type: GeminiEventType.Retry;
|
|
};
|
|
|
|
export type ServerGeminiAgentExecutionStoppedEvent = {
|
|
type: GeminiEventType.AgentExecutionStopped;
|
|
value: {
|
|
reason: string;
|
|
systemMessage?: string;
|
|
};
|
|
};
|
|
|
|
export type ServerGeminiAgentExecutionBlockedEvent = {
|
|
type: GeminiEventType.AgentExecutionBlocked;
|
|
value: {
|
|
reason: string;
|
|
systemMessage?: string;
|
|
};
|
|
};
|
|
|
|
export type ServerGeminiContextWindowWillOverflowEvent = {
|
|
type: GeminiEventType.ContextWindowWillOverflow;
|
|
value: {
|
|
estimatedRequestTokenCount: number;
|
|
remainingTokenCount: number;
|
|
};
|
|
};
|
|
|
|
export type ServerGeminiInvalidStreamEvent = {
|
|
type: GeminiEventType.InvalidStream;
|
|
};
|
|
|
|
export type ServerGeminiModelInfoEvent = {
|
|
type: GeminiEventType.ModelInfo;
|
|
value: string;
|
|
};
|
|
|
|
export interface StructuredError {
|
|
message: string;
|
|
status?: number;
|
|
}
|
|
|
|
export interface GeminiErrorEventValue {
|
|
error: StructuredError;
|
|
}
|
|
|
|
export interface GeminiFinishedEventValue {
|
|
reason: FinishReason | undefined;
|
|
usageMetadata: GenerateContentResponseUsageMetadata | undefined;
|
|
}
|
|
|
|
export interface ServerToolCallConfirmationDetails {
|
|
request: ToolCallRequestInfo;
|
|
details: ToolCallConfirmationDetails;
|
|
}
|
|
|
|
export type ServerGeminiContentEvent = {
|
|
type: GeminiEventType.Content;
|
|
value: string;
|
|
traceId?: string;
|
|
};
|
|
|
|
export type ServerGeminiThoughtEvent = {
|
|
type: GeminiEventType.Thought;
|
|
value: ThoughtSummary;
|
|
traceId?: string;
|
|
};
|
|
|
|
export type ServerGeminiToolCallRequestEvent = {
|
|
type: GeminiEventType.ToolCallRequest;
|
|
value: ToolCallRequestInfo;
|
|
};
|
|
|
|
export type ServerGeminiToolCallResponseEvent = {
|
|
type: GeminiEventType.ToolCallResponse;
|
|
value: ToolCallResponseInfo;
|
|
};
|
|
|
|
export type ServerGeminiToolCallConfirmationEvent = {
|
|
type: GeminiEventType.ToolCallConfirmation;
|
|
value: ServerToolCallConfirmationDetails;
|
|
};
|
|
|
|
export type ServerGeminiUserCancelledEvent = {
|
|
type: GeminiEventType.UserCancelled;
|
|
};
|
|
|
|
export type ServerGeminiErrorEvent = {
|
|
type: GeminiEventType.Error;
|
|
value: GeminiErrorEventValue;
|
|
};
|
|
|
|
export enum CompressionStatus {
|
|
/** The compression was successful */
|
|
COMPRESSED = 1,
|
|
|
|
/** The compression failed due to the compression inflating the token count */
|
|
COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
|
|
|
|
/** The compression failed due to an error counting tokens */
|
|
COMPRESSION_FAILED_TOKEN_COUNT_ERROR,
|
|
|
|
/** The compression was not necessary and no action was taken */
|
|
NOOP,
|
|
}
|
|
|
|
export interface ChatCompressionInfo {
|
|
originalTokenCount: number;
|
|
newTokenCount: number;
|
|
compressionStatus: CompressionStatus;
|
|
}
|
|
|
|
export type ServerGeminiChatCompressedEvent = {
|
|
type: GeminiEventType.ChatCompressed;
|
|
value: ChatCompressionInfo | null;
|
|
};
|
|
|
|
export type ServerGeminiMaxSessionTurnsEvent = {
|
|
type: GeminiEventType.MaxSessionTurns;
|
|
};
|
|
|
|
export type ServerGeminiFinishedEvent = {
|
|
type: GeminiEventType.Finished;
|
|
value: GeminiFinishedEventValue;
|
|
};
|
|
|
|
export type ServerGeminiLoopDetectedEvent = {
|
|
type: GeminiEventType.LoopDetected;
|
|
};
|
|
|
|
export type ServerGeminiCitationEvent = {
|
|
type: GeminiEventType.Citation;
|
|
value: string;
|
|
};
|
|
|
|
// The original union type, now composed of the individual types
|
|
export type ServerGeminiStreamEvent =
|
|
| ServerGeminiChatCompressedEvent
|
|
| ServerGeminiCitationEvent
|
|
| ServerGeminiContentEvent
|
|
| ServerGeminiErrorEvent
|
|
| ServerGeminiFinishedEvent
|
|
| ServerGeminiLoopDetectedEvent
|
|
| ServerGeminiMaxSessionTurnsEvent
|
|
| ServerGeminiThoughtEvent
|
|
| ServerGeminiToolCallConfirmationEvent
|
|
| ServerGeminiToolCallRequestEvent
|
|
| ServerGeminiToolCallResponseEvent
|
|
| ServerGeminiUserCancelledEvent
|
|
| ServerGeminiRetryEvent
|
|
| ServerGeminiContextWindowWillOverflowEvent
|
|
| ServerGeminiInvalidStreamEvent
|
|
| ServerGeminiModelInfoEvent
|
|
| ServerGeminiAgentExecutionStoppedEvent
|
|
| ServerGeminiAgentExecutionBlockedEvent;
|
|
|
|
// A turn manages the agentic loop turn within the server context.
|
|
export class Turn {
|
|
readonly pendingToolCalls: ToolCallRequestInfo[] = [];
|
|
private debugResponses: GenerateContentResponse[] = [];
|
|
private pendingCitations = new Set<string>();
|
|
finishReason: FinishReason | undefined = undefined;
|
|
|
|
constructor(
|
|
private readonly chat: GeminiChat,
|
|
private readonly prompt_id: string,
|
|
) {}
|
|
|
|
// The run method yields simpler events suitable for server logic
|
|
async *run(
|
|
modelConfigKey: ModelConfigKey,
|
|
req: PartListUnion,
|
|
signal: AbortSignal,
|
|
): AsyncGenerator<ServerGeminiStreamEvent> {
|
|
try {
|
|
// Note: This assumes `sendMessageStream` yields events like
|
|
// { type: StreamEventType.RETRY } or { type: StreamEventType.CHUNK, value: GenerateContentResponse }
|
|
const responseStream = await this.chat.sendMessageStream(
|
|
modelConfigKey,
|
|
req,
|
|
this.prompt_id,
|
|
signal,
|
|
);
|
|
|
|
for await (const streamEvent of responseStream) {
|
|
if (signal?.aborted) {
|
|
yield { type: GeminiEventType.UserCancelled };
|
|
return;
|
|
}
|
|
|
|
// Handle the new RETRY event
|
|
if (streamEvent.type === 'retry') {
|
|
yield { type: GeminiEventType.Retry };
|
|
continue; // Skip to the next event in the stream
|
|
}
|
|
|
|
if (streamEvent.type === 'agent_execution_stopped') {
|
|
yield {
|
|
type: GeminiEventType.AgentExecutionStopped,
|
|
value: { reason: streamEvent.reason },
|
|
};
|
|
return;
|
|
}
|
|
|
|
if (streamEvent.type === 'agent_execution_blocked') {
|
|
yield {
|
|
type: GeminiEventType.AgentExecutionBlocked,
|
|
value: { reason: streamEvent.reason },
|
|
};
|
|
continue;
|
|
}
|
|
|
|
// Assuming other events are chunks with a `value` property
|
|
const resp = streamEvent.value;
|
|
if (!resp) continue; // Skip if there's no response body
|
|
|
|
this.debugResponses.push(resp);
|
|
|
|
const traceId = resp.responseId;
|
|
|
|
const thoughtPart = resp.candidates?.[0]?.content?.parts?.[0];
|
|
if (thoughtPart?.thought) {
|
|
const thought = parseThought(thoughtPart.text ?? '');
|
|
yield {
|
|
type: GeminiEventType.Thought,
|
|
value: thought,
|
|
traceId,
|
|
};
|
|
continue;
|
|
}
|
|
|
|
const text = getResponseText(resp);
|
|
if (text) {
|
|
yield { type: GeminiEventType.Content, value: text, traceId };
|
|
}
|
|
|
|
// Handle function calls (requesting tool execution)
|
|
const functionCalls = resp.functionCalls ?? [];
|
|
for (const fnCall of functionCalls) {
|
|
const event = this.handlePendingFunctionCall(fnCall, traceId);
|
|
if (event) {
|
|
yield event;
|
|
}
|
|
}
|
|
|
|
for (const citation of getCitations(resp)) {
|
|
this.pendingCitations.add(citation);
|
|
}
|
|
|
|
// Check if response was truncated or stopped for various reasons
|
|
const finishReason = resp.candidates?.[0]?.finishReason;
|
|
|
|
// This is the key change: Only yield 'Finished' if there is a finishReason.
|
|
if (finishReason) {
|
|
if (this.pendingCitations.size > 0) {
|
|
yield {
|
|
type: GeminiEventType.Citation,
|
|
value: `Citations:\n${[...this.pendingCitations].sort().join('\n')}`,
|
|
};
|
|
this.pendingCitations.clear();
|
|
}
|
|
|
|
this.finishReason = finishReason;
|
|
yield {
|
|
type: GeminiEventType.Finished,
|
|
value: {
|
|
reason: finishReason,
|
|
usageMetadata: resp.usageMetadata,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (signal.aborted) {
|
|
yield { type: GeminiEventType.UserCancelled };
|
|
// Regular cancellation error, fail gracefully.
|
|
return;
|
|
}
|
|
|
|
if (e instanceof InvalidStreamError) {
|
|
yield { type: GeminiEventType.InvalidStream };
|
|
return;
|
|
}
|
|
|
|
const error = toFriendlyError(e);
|
|
if (error instanceof UnauthorizedError) {
|
|
throw error;
|
|
}
|
|
|
|
const contextForReport = [
|
|
...this.chat.getHistory(/*curated*/ true),
|
|
createUserContent(req),
|
|
];
|
|
await reportError(
|
|
error,
|
|
'Error when talking to Gemini API',
|
|
contextForReport,
|
|
'Turn.run-sendMessageStream',
|
|
);
|
|
const status =
|
|
typeof error === 'object' &&
|
|
error !== null &&
|
|
'status' in error &&
|
|
typeof (error as { status: unknown }).status === 'number'
|
|
? (error as { status: number }).status
|
|
: undefined;
|
|
const structuredError: StructuredError = {
|
|
message: getErrorMessage(error),
|
|
status,
|
|
};
|
|
await this.chat.maybeIncludeSchemaDepthContext(structuredError);
|
|
yield { type: GeminiEventType.Error, value: { error: structuredError } };
|
|
return;
|
|
}
|
|
}
|
|
|
|
private handlePendingFunctionCall(
|
|
fnCall: FunctionCall,
|
|
traceId?: string,
|
|
): ServerGeminiStreamEvent | null {
|
|
const callId =
|
|
fnCall.id ??
|
|
`${fnCall.name}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
const name = fnCall.name || 'undefined_tool_name';
|
|
const args = fnCall.args || {};
|
|
|
|
const toolCallRequest: ToolCallRequestInfo = {
|
|
callId,
|
|
name,
|
|
args,
|
|
isClientInitiated: false,
|
|
prompt_id: this.prompt_id,
|
|
traceId,
|
|
};
|
|
|
|
this.pendingToolCalls.push(toolCallRequest);
|
|
|
|
// Yield a request for the tool call, not the pending/confirming status
|
|
return { type: GeminiEventType.ToolCallRequest, value: toolCallRequest };
|
|
}
|
|
|
|
getDebugResponses(): GenerateContentResponse[] {
|
|
return this.debugResponses;
|
|
}
|
|
|
|
/**
|
|
* Get the concatenated response text from all responses in this turn.
|
|
* This extracts and joins all text content from the model's responses.
|
|
*/
|
|
getResponseText(): string {
|
|
return this.debugResponses
|
|
.map((response) => getResponseText(response))
|
|
.filter((text): text is string => text !== null)
|
|
.join(' ');
|
|
}
|
|
}
|