/** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ /** * @fileoverview Pure, stateless-per-call translation functions that convert * ServerGeminiStreamEvent objects into AgentEvent objects. * * No side effects, no generators. Each call to `translateEvent` takes an event * and mutable TranslationState, returning zero or more AgentEvents. */ import type { FinishReason } from '@google/genai'; import { GeminiEventType } from '../core/turn.js'; import type { ServerGeminiStreamEvent, StructuredError, GeminiFinishedEventValue, } from '../core/turn.js'; import type { AgentEvent, StreamEndReason, ErrorData, Usage, AgentEventType, } from './types.js'; import { geminiPartsToContentParts, toolResultDisplayToContentParts, buildToolResponseData, } from './content-utils.js'; // --------------------------------------------------------------------------- // Translation State // --------------------------------------------------------------------------- export interface TranslationState { streamId: string; streamStartEmitted: boolean; model: string | undefined; eventCounter: number; /** Tracks callId → tool name from requests so responses can reference the name. */ pendingToolNames: Map; } export function createTranslationState(streamId?: string): TranslationState { return { streamId: streamId ?? crypto.randomUUID(), streamStartEmitted: false, model: undefined, eventCounter: 0, pendingToolNames: new Map(), }; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeEvent( type: T, state: TranslationState, payload: Partial>, ): AgentEvent { const id = `${state.streamId}-${state.eventCounter++}`; // TypeScript cannot preserve the specific discriminated union member across // this generic object assembly, so keep the narrowing local to the event // constructor boundary. // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return { ...payload, id, timestamp: new Date().toISOString(), streamId: state.streamId, type, } as AgentEvent; } function ensureStreamStart(state: TranslationState, out: AgentEvent[]): void { if (!state.streamStartEmitted) { out.push(makeEvent('agent_start', state, {})); state.streamStartEmitted = true; } } // --------------------------------------------------------------------------- // Core Translator // --------------------------------------------------------------------------- /** * Translates a single ServerGeminiStreamEvent into zero or more AgentEvents. * Mutates `state` (counter, flags) as a side effect. */ export function translateEvent( event: ServerGeminiStreamEvent, state: TranslationState, ): AgentEvent[] { const out: AgentEvent[] = []; switch (event.type) { case GeminiEventType.ModelInfo: state.model = event.value; ensureStreamStart(state, out); out.push(makeEvent('session_update', state, { model: event.value })); break; case GeminiEventType.Content: ensureStreamStart(state, out); out.push( makeEvent('message', state, { role: 'agent', content: [{ type: 'text', text: event.value }], }), ); break; case GeminiEventType.Thought: ensureStreamStart(state, out); out.push( makeEvent('message', state, { role: 'agent', content: [{ type: 'thought', thought: event.value.description }], _meta: event.value.subject ? { source: 'agent', subject: event.value.subject } : { source: 'agent' }, }), ); break; case GeminiEventType.Citation: ensureStreamStart(state, out); out.push( makeEvent('message', state, { role: 'agent', content: [{ type: 'text', text: event.value }], _meta: { source: 'agent', citation: true }, }), ); break; case GeminiEventType.Finished: handleFinished(event.value, state, out); break; case GeminiEventType.Error: handleError(event.value.error, state, out); break; case GeminiEventType.UserCancelled: ensureStreamStart(state, out); out.push( makeEvent('agent_end', state, { reason: 'aborted', }), ); break; case GeminiEventType.MaxSessionTurns: ensureStreamStart(state, out); out.push( makeEvent('agent_end', state, { reason: 'max_turns', data: { code: 'MAX_TURNS_EXCEEDED', }, }), ); break; case GeminiEventType.LoopDetected: ensureStreamStart(state, out); out.push( makeEvent('error', state, { status: 'INTERNAL', message: 'Loop detected, stopping execution', fatal: false, _meta: { code: 'LOOP_DETECTED' }, }), ); break; case GeminiEventType.ContextWindowWillOverflow: ensureStreamStart(state, out); out.push( makeEvent('error', state, { status: 'RESOURCE_EXHAUSTED', message: `Context window will overflow (estimated: ${event.value.estimatedRequestTokenCount}, remaining: ${event.value.remainingTokenCount})`, fatal: true, }), ); break; case GeminiEventType.AgentExecutionStopped: ensureStreamStart(state, out); out.push( makeEvent('agent_end', state, { reason: 'completed', data: { message: event.value.systemMessage?.trim() || event.value.reason, }, }), ); break; case GeminiEventType.AgentExecutionBlocked: ensureStreamStart(state, out); out.push( makeEvent('error', state, { status: 'PERMISSION_DENIED', message: `Agent execution blocked: ${event.value.systemMessage?.trim() || event.value.reason}`, fatal: false, _meta: { code: 'AGENT_EXECUTION_BLOCKED' }, }), ); break; case GeminiEventType.InvalidStream: ensureStreamStart(state, out); out.push( makeEvent('error', state, { status: 'INTERNAL', message: 'Invalid stream received from model', fatal: true, }), ); break; case GeminiEventType.ToolCallRequest: ensureStreamStart(state, out); state.pendingToolNames.set(event.value.callId, event.value.name); out.push( makeEvent('tool_request', state, { requestId: event.value.callId, name: event.value.name, args: event.value.args, }), ); break; case GeminiEventType.ToolCallResponse: { ensureStreamStart(state, out); const displayContent = toolResultDisplayToContentParts( event.value.resultDisplay, ); const data = buildToolResponseData(event.value); out.push( makeEvent('tool_response', state, { requestId: event.value.callId, name: state.pendingToolNames.get(event.value.callId) ?? 'unknown', content: event.value.error ? [{ type: 'text', text: event.value.error.message }] : geminiPartsToContentParts(event.value.responseParts), isError: event.value.error !== undefined, ...(displayContent ? { displayContent } : {}), ...(data ? { data } : {}), }), ); state.pendingToolNames.delete(event.value.callId); break; } case GeminiEventType.ToolCallConfirmation: // Elicitations are handled separately by the session layer break; // Internal concerns — no AgentEvent emitted case GeminiEventType.ChatCompressed: case GeminiEventType.Retry: break; default: ((x: never) => { throw new Error(`Unhandled event type: ${JSON.stringify(x)}`); })(event); break; } return out; } // --------------------------------------------------------------------------- // Finished Event Handling // --------------------------------------------------------------------------- function handleFinished( value: GeminiFinishedEventValue, state: TranslationState, out: AgentEvent[], ): void { if (value.usageMetadata) { ensureStreamStart(state, out); const usage = mapUsage(value.usageMetadata, state.model); out.push(makeEvent('usage', state, usage)); } } // --------------------------------------------------------------------------- // Error Handling // --------------------------------------------------------------------------- function handleError( error: unknown, state: TranslationState, out: AgentEvent[], ): void { ensureStreamStart(state, out); const mapped = mapError(error); out.push(makeEvent('error', state, mapped)); } // --------------------------------------------------------------------------- // Public Mapping Functions // --------------------------------------------------------------------------- /** * Maps a Gemini FinishReason to an AgentEnd reason. */ export function mapFinishReason( reason: FinishReason | undefined, ): StreamEndReason { if (!reason) return 'completed'; switch (reason) { case 'STOP': case 'FINISH_REASON_UNSPECIFIED': return 'completed'; case 'MAX_TOKENS': return 'max_budget'; case 'SAFETY': case 'RECITATION': case 'LANGUAGE': case 'BLOCKLIST': case 'PROHIBITED_CONTENT': case 'SPII': case 'IMAGE_SAFETY': case 'IMAGE_PROHIBITED_CONTENT': return 'refusal'; case 'MALFORMED_FUNCTION_CALL': case 'OTHER': case 'UNEXPECTED_TOOL_CALL': case 'NO_IMAGE': return 'failed'; default: return 'failed'; } } /** * Maps an HTTP status code to a gRPC-style status string. */ export function mapHttpToGrpcStatus( httpStatus: number | undefined, ): ErrorData['status'] { if (httpStatus === undefined) return 'INTERNAL'; switch (httpStatus) { case 400: return 'INVALID_ARGUMENT'; case 401: return 'UNAUTHENTICATED'; case 403: return 'PERMISSION_DENIED'; case 404: return 'NOT_FOUND'; case 409: return 'ALREADY_EXISTS'; case 429: return 'RESOURCE_EXHAUSTED'; case 500: return 'INTERNAL'; case 501: return 'UNIMPLEMENTED'; case 503: return 'UNAVAILABLE'; case 504: return 'DEADLINE_EXCEEDED'; default: return 'INTERNAL'; } } /** * Maps a StructuredError (or unknown error value) to an ErrorData payload. * Preserves selected error metadata in _meta and includes raw structured * errors for lossless debugging. */ export function mapError( error: unknown, ): ErrorData & { _meta?: Record } { const meta: Record = {}; if (error instanceof Error) { meta['errorName'] = error.constructor.name; if ('exitCode' in error && typeof error.exitCode === 'number') { meta['exitCode'] = error.exitCode; } if ('code' in error) { meta['code'] = error.code; } } if (isStructuredError(error)) { const structuredMeta = { ...meta, rawError: error }; return { status: mapHttpToGrpcStatus(error.status), message: error.message, fatal: true, _meta: structuredMeta, }; } if (error instanceof Error) { return { status: 'INTERNAL', message: error.message, fatal: true, ...(Object.keys(meta).length > 0 ? { _meta: meta } : {}), }; } return { status: 'INTERNAL', message: String(error), fatal: true, }; } function isStructuredError(error: unknown): error is StructuredError { return ( typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string' ); } /** * Maps Gemini usageMetadata to Usage. */ export function mapUsage( metadata: { promptTokenCount?: number; candidatesTokenCount?: number; cachedContentTokenCount?: number; }, model?: string, ): Usage { return { model: model ?? 'unknown', inputTokens: metadata.promptTokenCount, outputTokens: metadata.candidatesTokenCount, cachedTokens: metadata.cachedContentTokenCount, }; }