diff --git a/core/package.json b/core/package.json index 8f80e46d..39ec78d0 100644 --- a/core/package.json +++ b/core/package.json @@ -45,7 +45,7 @@ "@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0", "@google-cloud/storage": "^7.17.1", "@google-cloud/vertexai": "^1.12.0", - "@google/genai": "^1.37.0", + "@google/genai": "^2.0.0", "@mikro-orm/core": "^6.6.10", "@mikro-orm/reflection": "^6.6.6", "@modelcontextprotocol/sdk": "^1.26.0", diff --git a/core/src/agents/llm_agent.ts b/core/src/agents/llm_agent.ts index 30aba287..ad8f2d0a 100644 --- a/core/src/agents/llm_agent.ts +++ b/core/src/agents/llm_agent.ts @@ -62,6 +62,7 @@ import {CONTENT_REQUEST_PROCESSOR} from './processors/content_request_processor. import {ContextCompactorRequestProcessor} from './processors/context_compactor_request_processor.js'; import {IDENTITY_LLM_REQUEST_PROCESSOR} from './processors/identity_llm_request_processor.js'; import {INSTRUCTIONS_LLM_REQUEST_PROCESSOR} from './processors/instructions_llm_request_processor.js'; +import {INTERACTIONS_REQUEST_PROCESSOR} from './processors/interactions_request_processor.js'; import {REQUEST_CONFIRMATION_LLM_REQUEST_PROCESSOR} from './processors/request_confirmation_llm_request_processor.js'; import {TOOL_FILTER_REQUEST_PROCESSOR} from './processors/tool_filter_request_processor.js'; import {ReadonlyContext} from './readonly_context.js'; @@ -396,6 +397,7 @@ export class LlmAgent extends BaseAgent { INSTRUCTIONS_LLM_REQUEST_PROCESSOR, REQUEST_CONFIRMATION_LLM_REQUEST_PROCESSOR, CONTENT_REQUEST_PROCESSOR, + INTERACTIONS_REQUEST_PROCESSOR, CODE_EXECUTION_REQUEST_PROCESSOR, TOOL_FILTER_REQUEST_PROCESSOR, ]; diff --git a/core/src/agents/processors/interactions_request_processor.ts b/core/src/agents/processors/interactions_request_processor.ts new file mode 100644 index 00000000..20c96d5d --- /dev/null +++ b/core/src/agents/processors/interactions_request_processor.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Event} from '../../events/event.js'; +import {Gemini} from '../../models/google_llm.js'; +import {LlmRequest} from '../../models/llm_request.js'; +import {InvocationContext} from '../invocation_context.js'; +import {isLlmAgent} from '../llm_agent.js'; +import {BaseLlmRequestProcessor} from './base_llm_processor.js'; + +/** + * Request processor for Gemini Interactions API. + * Resolves the previous interaction ID from the session history. + */ +export class InteractionsRequestProcessor implements BaseLlmRequestProcessor { + // eslint-disable-next-line require-yield + async *runAsync( + invocationContext: InvocationContext, + llmRequest: LlmRequest, + ): AsyncGenerator { + const agent = invocationContext.agent; + if (!agent || !isLlmAgent(agent)) { + return; + } + + const model = agent.canonicalModel; + if (model instanceof Gemini && model.useInteractionsApi) { + const events = invocationContext.session.events; + for (let i = events.length - 1; i >= 0; i--) { + const event = events[i]; + // Skip events not belonging to the current branch or author + if ( + event.branch === invocationContext.branch && + event.author === agent.name && + event.interactionId + ) { + llmRequest.previousInteractionId = event.interactionId; + break; + } + } + } + } +} + +export const INTERACTIONS_REQUEST_PROCESSOR = + new InteractionsRequestProcessor(); diff --git a/core/src/common.ts b/core/src/common.ts index 264b33dd..a9f602bb 100644 --- a/core/src/common.ts +++ b/core/src/common.ts @@ -47,6 +47,10 @@ export { ContentRequestProcessor, } from './agents/processors/content_request_processor.js'; export {ContextCompactorRequestProcessor} from './agents/processors/context_compactor_request_processor.js'; +export { + INTERACTIONS_REQUEST_PROCESSOR, + InteractionsRequestProcessor, +} from './agents/processors/interactions_request_processor.js'; export {ReadonlyContext} from './agents/readonly_context.js'; export {RoutedAgent, isRoutedAgent} from './agents/routed_agent.js'; export type {AgentRouter, RoutedAgentConfig} from './agents/routed_agent.js'; diff --git a/core/src/models/google_llm.ts b/core/src/models/google_llm.ts index 55737629..7785a496 100644 --- a/core/src/models/google_llm.ts +++ b/core/src/models/google_llm.ts @@ -20,6 +20,7 @@ import {StreamingResponseAggregator} from '../utils/streaming_utils.js'; import {BaseLlm} from './base_llm.js'; import {BaseLlmConnection} from './base_llm_connection.js'; import {GeminiLlmConnection} from './gemini_llm_connection.js'; +import {generateContentViaInteractions} from './interactions_utils.js'; import {LlmRequest} from './llm_request.js'; import {createLlmResponse, LlmResponse} from './llm_response.js'; @@ -53,6 +54,10 @@ export interface GeminiParams { * Headers to merge with internally crafted headers. */ headers?: Record; + /** + * Whether to use the Interactions API for stateful conversations. + */ + useInteractionsApi?: boolean; } /** @@ -64,6 +69,7 @@ export class Gemini extends BaseLlm { private readonly project?: string; private readonly location?: string; private readonly headers?: Record; + readonly useInteractionsApi: boolean; /** * @param params The parameters for creating a Gemini instance. @@ -75,6 +81,7 @@ export class Gemini extends BaseLlm { project, location, headers, + useInteractionsApi, }: GeminiParams) { if (!model) { model = 'gemini-2.5-flash'; @@ -99,6 +106,7 @@ export class Gemini extends BaseLlm { this.apiKey = params.apiKey; this.headers = headers; this.vertexai = !!params.vertexai; + this.useInteractionsApi = !!useInteractionsApi; } /** @@ -132,6 +140,10 @@ export class Gemini extends BaseLlm { stream = false, abortSignal?: AbortSignal, ): AsyncGenerator { + if (this.useInteractionsApi) { + yield* generateContentViaInteractions(this.apiClient, llmRequest, stream); + return; + } this.preprocessRequest(llmRequest); this.maybeAppendUserContent(llmRequest); logger.info( diff --git a/core/src/models/interactions_utils.ts b/core/src/models/interactions_utils.ts new file mode 100644 index 00000000..a23c6385 --- /dev/null +++ b/core/src/models/interactions_utils.ts @@ -0,0 +1,955 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { + Content, + FinishReason, + GenerateContentConfig, + GoogleGenAI, + Interactions, + Language, + Outcome, + Part, +} from '@google/genai'; +import {logger} from '../utils/logger.js'; +import {LlmRequest} from './llm_request.js'; +import {LlmResponse} from './llm_response.js'; + +// --- Helper Interfaces for Strong Typing --- + +interface ExtendedTool { + functionDeclarations?: Array<{ + name: string; + description?: string; + parameters?: { + properties?: Record; + required?: string[]; + }; + parametersJsonSchema?: unknown; + }>; + googleSearch?: unknown; + codeExecution?: unknown; + urlContext?: unknown; +} + +export interface ExtendedInteraction extends Interactions.Interaction { + error?: { + code: string; + message: string; + }; +} + +export interface ExtendedInteractionStatusUpdate extends Omit< + Interactions.InteractionStatusUpdate, + 'error' +> { + error?: { + code: string; + message: string; + }; +} + +// Runtime event types can be more relaxed than compile-time +export interface ExtendedInteractionSSEEvent extends Omit< + Interactions.InteractionSSEEvent, + 'error' | 'interaction_id' | 'status' | 'event_type' +> { + event_type?: string; + eventType?: string; + delta?: { + type: string; + text?: string; + name?: string; + id?: string; + arguments?: Record; + thought_signature?: string; + signature?: string; + data?: string; + uri?: string; + mime_type: string; + }; + status?: string; + error?: { + code: string; + message: string; + }; + code?: string; + message?: string; + interaction_id?: string; + interactionId?: string; + interaction?: { + id: string; + }; + id?: string; +} + +// --- Helper Functions --- + +/** + * Helper to determine interaction media type from mimeType string. + */ +function getInteractionMediaType( + mimeType: string, +): 'image' | 'audio' | 'video' | 'document' { + switch (mimeType.split('/')[0]) { + case 'image': + return 'image'; + case 'audio': + return 'audio'; + case 'video': + return 'video'; + default: + return 'document'; + } +} + +/** + * Extracts the latest turn contents for interactions API. + */ +export function getLatestUserContents(contents: Content[]): Content[] { + if (!contents || contents.length === 0) { + return []; + } + + // Find the latest continuous user messages from the end + const latestUserContents: Content[] = []; + for (let i = contents.length - 1; i >= 0; i--) { + const content = contents[i]; + if (content.role === 'user') { + latestUserContents.unshift(content); + } else { + // Stop when we hit a non-user message + break; + } + } + + // Check if the user contents contain a function_result + let hasFunctionResult = false; + for (const content of latestUserContents) { + if (content.parts) { + for (const part of content.parts) { + if ( + part.functionResponse !== undefined && + part.functionResponse !== null + ) { + hasFunctionResult = true; + break; + } + } + } + if (hasFunctionResult) { + break; + } + } + + // If we have a function_result, we also need the preceding model content + // with the function_call so the API can match the call_id + if (hasFunctionResult && contents.length > latestUserContents.length) { + const userStartIdx = contents.length - latestUserContents.length; + if (userStartIdx > 0) { + const precedingContent = contents[userStartIdx - 1]; + if (precedingContent.role === 'model' && precedingContent.parts) { + for (const part of precedingContent.parts) { + if (part.functionCall !== undefined && part.functionCall !== null) { + return [precedingContent, ...latestUserContents]; + } + } + } + } + } + + return latestUserContents; +} + +/** + * Convert a Part to a media content object (Interactions.Content). + */ +function convertPartToMediaContent(part: Part): Interactions.Content | null { + if (part.text !== undefined && part.text !== null) { + return {type: 'text', text: part.text}; + } + + if (part.inlineData !== undefined && part.inlineData !== null) { + const mimeType = part.inlineData.mimeType || ''; + return { + type: getInteractionMediaType(mimeType), + data: part.inlineData.data, + mime_type: mimeType, + } as Interactions.Content; + } + + if (part.fileData !== undefined && part.fileData !== null) { + const mimeType = part.fileData.mimeType || ''; + return { + type: getInteractionMediaType(mimeType), + uri: part.fileData.fileUri, + mime_type: mimeType, + } as Interactions.Content; + } + + return null; +} + +/** + * Convert a Content to a list of Steps. + */ +export function convertContentToSteps(content: Content): Interactions.Step[] { + const steps: Interactions.Step[] = []; + const role = content.role || 'user'; + + if (role === 'user') { + const mediaContents: Interactions.Content[] = []; + if (content.parts) { + for (const part of content.parts) { + if (part.functionResponse) { + steps.push({ + type: 'function_result', + call_id: part.functionResponse.id || '', + name: part.functionResponse.name || '', + result: part.functionResponse.response, + } as Interactions.FunctionResultStep); + } else if (part.codeExecutionResult) { + const isError = + part.codeExecutionResult.outcome === Outcome.OUTCOME_FAILED || + part.codeExecutionResult.outcome === + Outcome.OUTCOME_DEADLINE_EXCEEDED; + steps.push({ + type: 'code_execution_result', + call_id: '', + result: part.codeExecutionResult.output || '', + is_error: isError, + } as Interactions.CodeExecutionResultStep); + } else { + const mediaContent = convertPartToMediaContent(part); + if (mediaContent) { + mediaContents.push(mediaContent); + } + } + } + } + if (mediaContents.length > 0) { + steps.push({ + type: 'user_input', + content: mediaContents, + } as Interactions.UserInputStep); + } + } else if (role === 'model') { + const mediaContents: Interactions.Content[] = []; + if (content.parts) { + for (const part of content.parts) { + if (part.functionCall) { + const step: Interactions.FunctionCallStep = { + type: 'function_call', + id: part.functionCall.id || '', + name: part.functionCall.name || '', + arguments: + (part.functionCall.args as Record) || {}, + }; + if (part.thoughtSignature) { + step.signature = part.thoughtSignature; + } + steps.push(step); + } else if (part.executableCode) { + steps.push({ + type: 'code_execution_call', + id: '', + arguments: { + code: part.executableCode.code || '', + language: 'python', + }, + } as Interactions.CodeExecutionCallStep); + } else if (part.thought) { + const step: Interactions.ThoughtStep = { + type: 'thought', + }; + if (part.thoughtSignature) { + step.signature = part.thoughtSignature; + } + steps.push(step); + } else { + const mediaContent = convertPartToMediaContent(part); + if (mediaContent) { + mediaContents.push(mediaContent); + } + } + } + } + if (mediaContents.length > 0) { + steps.push({ + type: 'model_output', + content: mediaContents, + } as Interactions.ModelOutputStep); + } + } + + return steps; +} + +/** + * Convert a media content (Interactions.Content) to a Part. + */ +function convertMediaContentToPart(content: Interactions.Content): Part | null { + if (content.type === 'text') { + return {text: content.text || ''}; + } + + if ( + content.type === 'image' || + content.type === 'audio' || + content.type === 'video' || + content.type === 'document' + ) { + const media = content as { + data?: string; + uri?: string; + mime_type?: string; + }; + if (media.data) { + return { + inlineData: { + data: media.data, + mimeType: media.mime_type || '', + }, + }; + } else if (media.uri) { + return { + fileData: { + fileUri: media.uri, + mimeType: media.mime_type || '', + }, + }; + } + } + return null; +} + +/** + * Convert a Step to a list of Parts. + */ +export function convertStepToParts(step: Interactions.Step): Part[] { + if (!step || !step.type) { + return []; + } + + switch (step.type) { + case 'model_output': { + const modelOutputStep = step as Interactions.ModelOutputStep; + const parts: Part[] = []; + if (modelOutputStep.content) { + for (const content of modelOutputStep.content) { + const part = convertMediaContentToPart(content); + if (part) { + parts.push(part); + } + } + } + return parts; + } + case 'user_input': { + const userInputStep = step as Interactions.UserInputStep; + const parts: Part[] = []; + if (userInputStep.content) { + for (const content of userInputStep.content) { + const part = convertMediaContentToPart(content); + if (part) { + parts.push(part); + } + } + } + return parts; + } + case 'function_call': { + const functionCallStep = step as Interactions.FunctionCallStep; + const part: Part = { + functionCall: { + id: functionCallStep.id, + name: functionCallStep.name, + args: functionCallStep.arguments || {}, + }, + }; + if (functionCallStep.signature) { + part.thoughtSignature = functionCallStep.signature; + } + return [part]; + } + case 'function_result': { + const functionResultStep = step as Interactions.FunctionResultStep; + const result = functionResultStep.result; + return [ + { + functionResponse: { + id: functionResultStep.call_id, + name: functionResultStep.name || '', + response: + typeof result === 'object' && result !== null + ? (result as Record) + : {output: result}, + }, + }, + ]; + } + case 'code_execution_call': { + const codeExecutionCallStep = step as Interactions.CodeExecutionCallStep; + const args = codeExecutionCallStep.arguments || {}; + return [ + { + executableCode: { + code: args.code || '', + language: (args.language || 'PYTHON').toUpperCase() as Language, + }, + }, + ]; + } + case 'code_execution_result': { + const codeExecutionResultStep = + step as Interactions.CodeExecutionResultStep; + return [ + { + codeExecutionResult: { + output: codeExecutionResultStep.result || '', + outcome: codeExecutionResultStep.is_error + ? Outcome.OUTCOME_FAILED + : Outcome.OUTCOME_OK, + }, + }, + ]; + } + case 'thought': { + const thoughtStep = step as Interactions.ThoughtStep; + const part: Part = { + thought: true, + }; + if (thoughtStep.signature) { + part.thoughtSignature = thoughtStep.signature; + } + return [part]; + } + default: + return []; + } +} + +/** + * Convert tools config to interactions format. + */ +export function convertToolsConfigToInteractionsFormat( + config: GenerateContentConfig, +): Interactions.Tool[] { + if (!config.tools) { + return []; + } + + const interactionTools: Interactions.Tool[] = []; + for (const tool of config.tools) { + const t = tool as ExtendedTool; + if (t.functionDeclarations) { + for (const funcDecl of t.functionDeclarations) { + const funcTool: { + type: 'function'; + name: string; + description?: string; + parameters?: unknown; + } = { + type: 'function', + name: funcDecl.name, + }; + if (funcDecl.description) { + funcTool.description = funcDecl.description; + } + if (funcDecl.parameters) { + if (funcDecl.parameters.properties) { + const props: Record = {}; + for (const [k, v] of Object.entries( + funcDecl.parameters.properties, + )) { + props[k] = JSON.parse(JSON.stringify(v)); + } + funcTool.parameters = { + type: 'object', + properties: props, + required: funcDecl.parameters.required + ? [...funcDecl.parameters.required] + : undefined, + }; + } + } else if (funcDecl.parametersJsonSchema) { + funcTool.parameters = funcDecl.parametersJsonSchema; + } + interactionTools.push(funcTool as Interactions.Tool); + } + } + + if (t.googleSearch) { + interactionTools.push({type: 'google_search'} as Interactions.Tool); + } + + if (t.codeExecution) { + interactionTools.push({type: 'code_execution'} as Interactions.Tool); + } + + if (t.urlContext) { + interactionTools.push({type: 'url_context'} as Interactions.Tool); + } + } + + return interactionTools; +} + +/** + * Helper to find the last element in an array matching a predicate. + */ +function findLastPart( + parts: Part[], + predicate: (p: Part) => boolean, +): Part | undefined { + for (let i = parts.length - 1; i >= 0; i--) { + if (predicate(parts[i])) { + return parts[i]; + } + } + return undefined; +} + +/** + * Extract the latest model generated parts from a list of steps. + */ +export function getLatestModelParts(steps: Interactions.Step[]): Part[] { + if (!steps || steps.length === 0) { + return []; + } + + const latestParts: Part[] = []; + for (let i = steps.length - 1; i >= 0; i--) { + const step = steps[i]; + if ( + step.type === 'user_input' || + step.type === 'function_result' || + step.type === 'code_execution_result' + ) { + break; + } + const parts = convertStepToParts(step); + latestParts.unshift(...parts); + } + return latestParts; +} + +/** + * Convert Interaction response to an LlmResponse. + */ +export function convertInteractionToLlmResponse( + interaction: ExtendedInteraction, +): LlmResponse { + if (interaction.status === 'failed') { + let errorMsg = 'Unknown error'; + let errorCode = 'UNKNOWN_ERROR'; + if (interaction.error) { + errorMsg = interaction.error.message || errorMsg; + errorCode = interaction.error.code || errorCode; + } + return { + errorCode: errorCode, + errorMessage: errorMsg, + interactionId: interaction.id, + }; + } + + const parts = getLatestModelParts(interaction.steps || []); + + let content: Content | undefined = undefined; + if (parts.length > 0) { + content = {role: 'model', parts: parts}; + } + + let usageMetadata: LlmResponse['usageMetadata'] = undefined; + if (interaction.usage) { + const inputTokens = interaction.usage.total_input_tokens || 0; + const outputTokens = interaction.usage.total_output_tokens || 0; + usageMetadata = { + promptTokenCount: inputTokens, + candidatesTokenCount: outputTokens, + totalTokenCount: inputTokens + outputTokens, + }; + } + + let finishReason: FinishReason | undefined = undefined; + if ( + interaction.status === 'completed' || + interaction.status === 'requires_action' + ) { + finishReason = 'STOP' as FinishReason; + } + + return { + content: content, + usageMetadata: usageMetadata, + finishReason: finishReason, + turnComplete: + interaction.status === 'completed' || + interaction.status === 'requires_action', + interactionId: interaction.id, + }; +} + +/** + * Convert InteractionSSEEvent to LlmResponse. + */ +export function convertInteractionEventToLlmResponse( + event: ExtendedInteractionSSEEvent, + aggregatedParts: Part[], + interactionId?: string, +): LlmResponse | null { + const eventType = event.event_type || event.eventType; + + if (eventType === 'step.start') { + const stepStart = event as unknown as Interactions.StepStart; + const step = stepStart.step; + if (step.type === 'function_call') { + const part: Part = { + functionCall: { + id: step.id, + name: step.name, + args: {}, + }, + partMetadata: { + accumulatedArgs: '', + isComplete: false, + }, + }; + if (step.signature) { + part.thoughtSignature = step.signature; + } + aggregatedParts.push(part); + return null; + } + if (step.type === 'thought') { + const part: Part = { + thought: true, + partMetadata: { + isComplete: false, + }, + }; + if (step.signature) { + part.thoughtSignature = step.signature; + } + aggregatedParts.push(part); + return null; + } + } else if (eventType === 'step.delta') { + const stepDelta = event as unknown as Interactions.StepDelta; + const delta = stepDelta.delta; + if (!delta) { + return null; + } + + if (delta.type === 'text') { + const text = delta.text || ''; + if (text) { + const part: Part = {text: text}; + aggregatedParts.push(part); + return { + content: {role: 'model', parts: [part]}, + partial: true, + turnComplete: false, + interactionId: interactionId, + }; + } + } else if (delta.type === 'arguments_delta') { + const activePart = findLastPart( + aggregatedParts, + (p) => + !!(p.functionCall && p.partMetadata && !p.partMetadata.isComplete), + ); + if (activePart && activePart.partMetadata && delta.arguments) { + activePart.partMetadata.accumulatedArgs = + (activePart.partMetadata.accumulatedArgs as string) + delta.arguments; + } + return null; + } else if (delta.type === 'thought_signature') { + const activePart = findLastPart( + aggregatedParts, + (p) => + !!( + (p.thought || p.functionCall) && + p.partMetadata && + !p.partMetadata.isComplete + ), + ); + if (activePart && delta.signature) { + activePart.thoughtSignature = delta.signature; + } + return null; + } else if ( + delta.type === 'image' || + delta.type === 'audio' || + delta.type === 'video' || + delta.type === 'document' + ) { + const part = convertMediaContentToPart(delta as Interactions.Content); + if (part) { + aggregatedParts.push(part); + return { + content: {role: 'model', parts: [part]}, + partial: false, + turnComplete: false, + interactionId: interactionId, + }; + } + } + } else if (eventType === 'step.stop') { + const activePart = findLastPart( + aggregatedParts, + (p) => !!(p.partMetadata && !p.partMetadata.isComplete), + ); + if (activePart && activePart.partMetadata) { + activePart.partMetadata.isComplete = true; + if (activePart.functionCall) { + const accumulatedArgs = activePart.partMetadata + .accumulatedArgs as string; + try { + activePart.functionCall.args = accumulatedArgs + ? JSON.parse(accumulatedArgs) + : {}; + } catch (e) { + logger.error( + `Failed to parse accumulated arguments: ${accumulatedArgs}`, + e, + ); + activePart.functionCall.args = {}; + } + delete activePart.partMetadata; + + return { + content: {role: 'model', parts: [activePart]}, + partial: false, + turnComplete: false, + interactionId: interactionId, + }; + } + if (activePart.thought) { + delete activePart.partMetadata; + return null; + } + } + } else if (eventType === 'interaction.completed') { + return { + content: + aggregatedParts.length > 0 + ? {role: 'model', parts: [...aggregatedParts]} + : undefined, + partial: false, + turnComplete: true, + finishReason: 'STOP' as FinishReason, + interactionId: interactionId, + }; + } else if (eventType === 'interaction.status_update') { + const statusUpdate = event as unknown as ExtendedInteractionStatusUpdate; + const status = statusUpdate.status; + if (status === 'completed' || status === 'requires_action') { + return { + content: + aggregatedParts.length > 0 + ? {role: 'model', parts: [...aggregatedParts]} + : undefined, + partial: false, + turnComplete: true, + finishReason: 'STOP' as FinishReason, + interactionId: interactionId, + }; + } else if (status === 'failed') { + const error = statusUpdate.error; + return { + errorCode: error ? error.code : 'UNKNOWN_ERROR', + errorMessage: error ? error.message : 'Unknown error', + turnComplete: true, + interactionId: interactionId, + }; + } + } else if (eventType === 'error') { + return { + errorCode: event.error?.code || event.code || 'UNKNOWN_ERROR', + errorMessage: event.error?.message || event.message || 'Unknown error', + turnComplete: true, + interactionId: interactionId, + }; + } + + return null; +} + +/** + * Build generation config. + */ +export function buildGenerationConfig( + config: GenerateContentConfig, +): Record { + const generationConfig: Record = {}; + if (config.temperature !== undefined && config.temperature !== null) { + generationConfig['temperature'] = config.temperature; + } + if (config.topP !== undefined && config.topP !== null) { + generationConfig['top_p'] = config.topP; + } + if (config.topK !== undefined && config.topK !== null) { + generationConfig['top_k'] = config.topK; + } + if (config.maxOutputTokens !== undefined && config.maxOutputTokens !== null) { + generationConfig['max_output_tokens'] = config.maxOutputTokens; + } + if (config.stopSequences) { + generationConfig['stop_sequences'] = config.stopSequences; + } + if (config.presencePenalty !== undefined && config.presencePenalty !== null) { + generationConfig['presence_penalty'] = config.presencePenalty; + } + if ( + config.frequencyPenalty !== undefined && + config.frequencyPenalty !== null + ) { + generationConfig['frequency_penalty'] = config.frequencyPenalty; + } + return generationConfig; +} + +/** + * Extract system instruction. + */ +export function extractSystemInstruction( + config: GenerateContentConfig, +): string | undefined { + const systemInstruction = config.systemInstruction; + if (!systemInstruction) { + return undefined; + } + + if (typeof systemInstruction === 'string') { + return systemInstruction; + } + + if ( + typeof systemInstruction === 'object' && + 'parts' in systemInstruction && + Array.isArray(systemInstruction.parts) + ) { + const texts: string[] = []; + for (const part of systemInstruction.parts) { + const p = part as Part; + if (p.text) { + texts.push(p.text); + } + } + return texts.length > 0 ? texts.join('\n') : undefined; + } + + return undefined; +} + +/** + * Extract stream interaction ID helper. + */ +function extractStreamInteractionId( + event: ExtendedInteractionSSEEvent, +): string | undefined { + if (event.interaction_id || event.interactionId) { + return event.interaction_id || event.interactionId; + } + + if (event.interaction && event.interaction.id) { + return event.interaction.id; + } + + if (event.event_type === 'interaction' || event.eventType === 'interaction') { + return event.id; + } + + return undefined; +} + +/** + * Generate content using the interactions API. + */ +export async function* generateContentViaInteractions( + apiClient: GoogleGenAI, + llmRequest: LlmRequest, + stream: boolean, +): AsyncGenerator { + let contents = llmRequest.contents; + if (llmRequest.previousInteractionId && contents) { + contents = getLatestUserContents(contents); + } + + const inputSteps: Interactions.Step[] = []; + if (contents) { + for (const content of contents) { + inputSteps.push(...convertContentToSteps(content)); + } + } + const interactionTools = convertToolsConfigToInteractionsFormat( + llmRequest.config || {}, + ); + const systemInstruction = extractSystemInstruction(llmRequest.config || {}); + const generationConfig = buildGenerationConfig(llmRequest.config || {}); + const previousInteractionId = llmRequest.previousInteractionId; + + logger.info( + `Sending request via interactions API, model: ${llmRequest.model}, stream: ${stream}, previous_interaction_id: ${previousInteractionId}`, + ); + + let currentInteractionId = previousInteractionId; + + if (stream) { + const responses = (await apiClient.interactions.create({ + model: (llmRequest.model || 'gemini-2.5-flash') as 'gemini-2.5-flash', + input: inputSteps, + stream: true, + system_instruction: systemInstruction, + tools: interactionTools.length > 0 ? interactionTools : undefined, + generation_config: + Object.keys(generationConfig).length > 0 ? generationConfig : undefined, + previous_interaction_id: previousInteractionId, + })) as AsyncIterable; + + const aggregatedParts: Part[] = []; + for await (const event of responses) { + const sseEvent = event as ExtendedInteractionSSEEvent; + const interactionId = extractStreamInteractionId(sseEvent); + if (interactionId) { + currentInteractionId = interactionId; + } + const llmResponse = convertInteractionEventToLlmResponse( + sseEvent, + aggregatedParts, + currentInteractionId, + ); + if (llmResponse) { + yield llmResponse; + } + } + + if (aggregatedParts.length > 0) { + yield { + content: {role: 'model', parts: aggregatedParts}, + partial: false, + turnComplete: true, + finishReason: 'STOP' as FinishReason, + interactionId: currentInteractionId, + }; + } + } else { + const interaction = (await apiClient.interactions.create({ + model: (llmRequest.model || 'gemini-2.5-flash') as 'gemini-2.5-flash', + input: inputSteps, + stream: false, + system_instruction: systemInstruction, + tools: interactionTools.length > 0 ? interactionTools : undefined, + generation_config: + Object.keys(generationConfig).length > 0 ? generationConfig : undefined, + previous_interaction_id: previousInteractionId, + })) as ExtendedInteraction; + + logger.info('Interaction response received from the model.'); + yield convertInteractionToLlmResponse(interaction); + } +} diff --git a/core/src/models/llm_request.ts b/core/src/models/llm_request.ts index 676780fb..9545858d 100644 --- a/core/src/models/llm_request.ts +++ b/core/src/models/llm_request.ts @@ -46,6 +46,11 @@ export interface LlmRequest { * The set of allowed tools, populated by request processors. */ allowedTools?: string[]; + + /** + * The interaction ID from the previous turn, if any. + */ + previousInteractionId?: string; } /** diff --git a/core/src/models/llm_response.ts b/core/src/models/llm_response.ts index 4a869c7d..de2e4726 100644 --- a/core/src/models/llm_response.ts +++ b/core/src/models/llm_response.ts @@ -94,6 +94,11 @@ export interface LlmResponse { * Audio transcription of model output. */ outputTranscription?: Transcription; + + /** + * The interaction ID returned by the model, if any. + */ + interactionId?: string; } /** diff --git a/core/src/utils/env_aware_utils.ts b/core/src/utils/env_aware_utils.ts index 70931eef..08d9aa49 100644 --- a/core/src/utils/env_aware_utils.ts +++ b/core/src/utils/env_aware_utils.ts @@ -34,15 +34,24 @@ export function randomUUID() { } /** - * Encodes the given string to base64. + * Encodes the given string or Uint8Array to base64. * - * @param data The string to encode. + * @param data The data to encode. * @return The base64-encoded string. */ -export function base64Encode(data: string): string { +export function base64Encode(data: string | Uint8Array): string { if (isBrowser()) { + let strData = ''; + if (typeof data === 'string') { + strData = data; + } else { + const len = data.byteLength; + for (let i = 0; i < len; i++) { + strData += String.fromCharCode(data[i]); + } + } // eslint-disable-next-line no-undef - return window.btoa(data); + return window.btoa(strData); } return Buffer.from(data).toString('base64'); diff --git a/core/test/agents/processors/interactions_request_processor_test.ts b/core/test/agents/processors/interactions_request_processor_test.ts new file mode 100644 index 00000000..e630fcb9 --- /dev/null +++ b/core/test/agents/processors/interactions_request_processor_test.ts @@ -0,0 +1,252 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { + BaseAgent, + BaseLlm, + Event, + EventActions, + Gemini, + INTERACTIONS_REQUEST_PROCESSOR, + InvocationContext, + LlmAgent, + LlmRequest, + PluginManager, + Session, +} from '@google/adk'; +import {describe, expect, it} from 'vitest'; + +class MockLlm extends BaseLlm { + constructor() { + super({model: 'mock-model'}); + } + + override async *generateContentAsync( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + llmRequest: LlmRequest, + ): AsyncGenerator {} + // eslint-disable-next-line @typescript-eslint/no-unused-vars + override async connect(llmRequest: LlmRequest): Promise { + return {} as any; + } +} + +function createMockEvent( + id: string, + author: string, + branch: string, + interactionId?: string, +): Event { + return { + id, + invocationId: 'test-invoc', + author, + branch, + interactionId, + actions: {} as EventActions, + timestamp: Date.now(), + }; +} + +function createMockInvocationContext( + events: Event[], + model: any, + agentName = 'test_agent', +): InvocationContext { + const session = { + id: 'test-session', + events, + appName: 'test-app', + userId: 'test-user', + } as unknown as Session; + + const agent = new LlmAgent({ + name: agentName, + model: model, + }); + + return new InvocationContext({ + invocationId: 'test-invocation', + agent: agent as BaseAgent, + session, + pluginManager: new PluginManager([]), + }); +} + +describe('InteractionsRequestProcessor', () => { + it('should not set previousInteractionId if model is not Gemini', async () => { + const rawEvents: Event[] = [ + createMockEvent('1', 'test_agent', 'main', 'int-1'), + ]; + const mockModel = new MockLlm(); + const invocationContext = createMockInvocationContext(rawEvents, mockModel); + const llmRequest: LlmRequest = { + contents: [], + toolsDict: {}, + liveConnectConfig: {}, + }; + + for await (const _ of INTERACTIONS_REQUEST_PROCESSOR.runAsync( + invocationContext, + llmRequest, + )) { + // intentionally empty + } + + expect(llmRequest.previousInteractionId).toBeUndefined(); + }); + + it('should not set previousInteractionId if model is Gemini but useInteractionsApi is false', async () => { + const rawEvents: Event[] = [ + createMockEvent('1', 'test_agent', 'main', 'int-1'), + ]; + const geminiModel = new Gemini({ + model: 'gemini-2.5-flash', + apiKey: 'dummy', + useInteractionsApi: false, + }); + const invocationContext = createMockInvocationContext( + rawEvents, + geminiModel, + ); + const llmRequest: LlmRequest = { + contents: [], + toolsDict: {}, + liveConnectConfig: {}, + }; + + for await (const _ of INTERACTIONS_REQUEST_PROCESSOR.runAsync( + invocationContext, + llmRequest, + )) { + // intentionally empty + } + + expect(llmRequest.previousInteractionId).toBeUndefined(); + }); + + it('should set previousInteractionId to latest interactionId from history if model is Gemini and useInteractionsApi is true', async () => { + const rawEvents: Event[] = [ + createMockEvent('1', 'test_agent', 'main', 'int-1'), + createMockEvent('2', 'test_agent', 'main', 'int-2'), + ]; + const geminiModel = new Gemini({ + model: 'gemini-2.5-flash', + apiKey: 'dummy', + useInteractionsApi: true, + }); + const invocationContext = createMockInvocationContext( + rawEvents, + geminiModel, + ); + invocationContext.branch = 'main'; + const llmRequest: LlmRequest = { + contents: [], + toolsDict: {}, + liveConnectConfig: {}, + }; + + for await (const _ of INTERACTIONS_REQUEST_PROCESSOR.runAsync( + invocationContext, + llmRequest, + )) { + // intentionally empty + } + + expect(llmRequest.previousInteractionId).toBe('int-2'); + }); + + it('should ignore events from other branches', async () => { + const rawEvents: Event[] = [ + createMockEvent('1', 'test_agent', 'other-branch', 'int-1'), + createMockEvent('2', 'test_agent', 'main', 'int-2'), + ]; + const geminiModel = new Gemini({ + model: 'gemini-2.5-flash', + apiKey: 'dummy', + useInteractionsApi: true, + }); + const invocationContext = createMockInvocationContext( + rawEvents, + geminiModel, + ); + invocationContext.branch = 'main'; + + const llmRequest: LlmRequest = { + contents: [], + toolsDict: {}, + liveConnectConfig: {}, + }; + + for await (const _ of INTERACTIONS_REQUEST_PROCESSOR.runAsync( + invocationContext, + llmRequest, + )) { + // intentionally empty + } + + expect(llmRequest.previousInteractionId).toBe('int-2'); + }); + + it('should ignore events from other authors', async () => { + const rawEvents: Event[] = [ + createMockEvent('1', 'other_agent', 'main', 'int-1'), + createMockEvent('2', 'test_agent', 'main', 'int-2'), + ]; + const geminiModel = new Gemini({ + model: 'gemini-2.5-flash', + apiKey: 'dummy', + useInteractionsApi: true, + }); + const invocationContext = createMockInvocationContext( + rawEvents, + geminiModel, + 'test_agent', + ); + invocationContext.branch = 'main'; + const llmRequest: LlmRequest = { + contents: [], + toolsDict: {}, + liveConnectConfig: {}, + }; + + for await (const _ of INTERACTIONS_REQUEST_PROCESSOR.runAsync( + invocationContext, + llmRequest, + )) { + // intentionally empty + } + + expect(llmRequest.previousInteractionId).toBe('int-2'); + }); + + it('should do nothing if agent is not LlmAgent', async () => { + const rawEvents: Event[] = [ + createMockEvent('1', 'test_agent', 'main', 'int-1'), + ]; + const invocationContext = createMockInvocationContext( + rawEvents, + new MockLlm(), + ); + (invocationContext as any).agent = {name: 'not-an-llm-agent'}; + const llmRequest: LlmRequest = { + contents: [], + toolsDict: {}, + liveConnectConfig: {}, + }; + + for await (const _ of INTERACTIONS_REQUEST_PROCESSOR.runAsync( + invocationContext, + llmRequest, + )) { + // intentionally empty + } + + expect(llmRequest.previousInteractionId).toBeUndefined(); + }); +}); diff --git a/core/test/models/interactions_utils_test.ts b/core/test/models/interactions_utils_test.ts new file mode 100644 index 00000000..8403c2ac --- /dev/null +++ b/core/test/models/interactions_utils_test.ts @@ -0,0 +1,2198 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { + Content, + FinishReason, + FunctionCall, + FunctionResponse, + GenerateContentConfig, + Interactions, + Language, + Outcome, + Part, +} from '@google/genai'; +import {describe, expect, it, vi} from 'vitest'; +import { + convertContentToSteps, + convertInteractionEventToLlmResponse, + convertInteractionToLlmResponse, + convertStepToParts, + convertToolsConfigToInteractionsFormat, + extractSystemInstruction, + generateContentViaInteractions, + getLatestUserContents, +} from '../../src/models/interactions_utils.js'; + +describe('interactions_utils', () => { + describe('getLatestUserContents', () => { + it('should return empty array for empty input', () => { + expect(getLatestUserContents([])).toEqual([]); + }); + + it('should return only the latest continuous user messages', () => { + const contents: Content[] = [ + {role: 'user', parts: [{text: 'Hello'}]}, + {role: 'model', parts: [{text: 'Hi'}]}, + {role: 'user', parts: [{text: 'How are you?'}]}, + {role: 'user', parts: [{text: 'Today is sunny'}]}, + ]; + + const expected: Content[] = [ + {role: 'user', parts: [{text: 'How are you?'}]}, + {role: 'user', parts: [{text: 'Today is sunny'}]}, + ]; + + expect(getLatestUserContents(contents)).toEqual(expected); + }); + + it('should include preceding model function call when user content has function response', () => { + const contents: Content[] = [ + {role: 'user', parts: [{text: 'Call tool'}]}, + { + role: 'model', + parts: [ + { + functionCall: { + name: 'my_tool', + args: {arg1: 'val1'}, + id: 'call-1', + } as FunctionCall, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'my_tool', + response: {result: 'success'}, + id: 'call-1', + } as FunctionResponse, + }, + ], + }, + ]; + + const expected: Content[] = [ + contents[1], // model function call + contents[2], // user function response + ]; + + expect(getLatestUserContents(contents)).toEqual(expected); + }); + + it('should not include preceding turn if it is not a model turn with function call', () => { + const contents: Content[] = [ + {role: 'model', parts: [{text: 'some model text'}]}, + { + role: 'user', + parts: [ + { + functionResponse: { + name: 'my_tool', + response: {result: 'success'}, + id: 'call-1', + } as FunctionResponse, + }, + ], + }, + ]; + const expected: Content[] = [contents[1]]; + expect(getLatestUserContents(contents)).toEqual(expected); + }); + }); + + describe('convertContentToSteps', () => { + it('should convert text part to user_input step', () => { + const content: Content = { + role: 'user', + parts: [{text: 'Hello'}], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'user_input', + content: [{type: 'text', text: 'Hello'}], + }, + ]); + }); + + it('should convert function call part to function_call step', () => { + const content: Content = { + role: 'model', + parts: [ + { + functionCall: { + name: 'test_tool', + args: {a: 1}, + id: 'call-123', + } as FunctionCall, + }, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'function_call', + id: 'call-123', + name: 'test_tool', + arguments: {a: 1}, + }, + ]); + }); + + it('should convert function call part with missing id and args to function_call step', () => { + const content: Content = { + role: 'model', + parts: [ + { + functionCall: { + name: 'test_tool', + } as FunctionCall, + }, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'function_call', + id: '', + name: 'test_tool', + arguments: {}, + }, + ]); + }); + + it('should convert function call part with thought signature to function_call step', () => { + const content: Content = { + role: 'model', + parts: [ + { + functionCall: { + name: 'test_tool', + args: {a: 1}, + id: 'call-123', + } as FunctionCall, + thoughtSignature: 'sig-data-string', + }, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'function_call', + id: 'call-123', + name: 'test_tool', + arguments: {a: 1}, + signature: 'sig-data-string', + }, + ]); + }); + + it('should convert function response part to function_result step', () => { + const content: Content = { + role: 'user', + parts: [ + { + functionResponse: { + name: 'test_tool', + response: {result: 'ok'}, + id: 'call-123', + } as FunctionResponse, + }, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'function_result', + name: 'test_tool', + call_id: 'call-123', + result: {result: 'ok'}, + }, + ]); + }); + + it('should convert function response part with missing name and id to function_result step', () => { + const content: Content = { + role: 'user', + parts: [ + { + functionResponse: { + response: {result: 'ok'}, + } as FunctionResponse, + }, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'function_result', + name: '', + call_id: '', + result: {result: 'ok'}, + }, + ]); + }); + + it('should convert inline image data to user_input step with image content', () => { + const content: Content = { + role: 'user', + parts: [ + { + inlineData: { + data: 'base64data', + mimeType: 'image/png', + }, + }, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'user_input', + content: [ + { + type: 'image', + data: 'base64data', + mime_type: 'image/png', + } as any, + ], + }, + ]); + }); + + it('should convert file image data to user_input step with image content', () => { + const content: Content = { + role: 'user', + parts: [ + { + fileData: { + fileUri: 'gs://bucket/img.png', + mimeType: 'image/png', + }, + }, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'user_input', + content: [ + { + type: 'image', + uri: 'gs://bucket/img.png', + mime_type: 'image/png', + } as any, + ], + }, + ]); + }); + + it('should convert code execution result to code_execution_result step', () => { + const content: Content = { + role: 'user', + parts: [ + { + codeExecutionResult: { + output: 'success output', + outcome: Outcome.OUTCOME_OK, + }, + }, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'code_execution_result', + call_id: '', + result: 'success output', + is_error: false, + }, + ]); + }); + + it('should convert executable code to code_execution_call step', () => { + const content: Content = { + role: 'model', + parts: [ + { + executableCode: { + code: 'print("hello")', + language: Language.PYTHON, + }, + }, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'code_execution_call', + id: '', + arguments: { + code: 'print("hello")', + language: 'python', + }, + }, + ]); + }); + + it('should convert thought part to thought step', () => { + const content: Content = { + role: 'model', + parts: [ + { + thought: true, + thoughtSignature: 'sig-data-string', + } as any, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'thought', + signature: 'sig-data-string', + }, + ]); + }); + + it('should convert inline audio data to user_input step with audio content', () => { + const content: Content = { + role: 'user', + parts: [ + { + inlineData: { + data: 'audiodata', + mimeType: 'audio/mp3', + }, + }, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'user_input', + content: [ + { + type: 'audio', + data: 'audiodata', + mime_type: 'audio/mp3', + } as any, + ], + }, + ]); + }); + + it('should convert inline video data to user_input step with video content', () => { + const content: Content = { + role: 'user', + parts: [ + { + inlineData: { + data: 'videodata', + mimeType: 'video/mp4', + }, + }, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'user_input', + content: [ + { + type: 'video', + data: 'videodata', + mime_type: 'video/mp4', + } as any, + ], + }, + ]); + }); + + it('should convert inline document data to user_input step with document content', () => { + const content: Content = { + role: 'user', + parts: [ + { + inlineData: { + data: 'docdata', + mimeType: 'application/pdf', + }, + }, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'user_input', + content: [ + { + type: 'document', + data: 'docdata', + mime_type: 'application/pdf', + } as any, + ], + }, + ]); + }); + + it('should convert file audio data to user_input step with audio content', () => { + const content: Content = { + role: 'user', + parts: [ + { + fileData: { + fileUri: 'gs://bucket/audio.mp3', + mimeType: 'audio/mp3', + }, + }, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'user_input', + content: [ + { + type: 'audio', + uri: 'gs://bucket/audio.mp3', + mime_type: 'audio/mp3', + } as any, + ], + }, + ]); + }); + + it('should convert file video data to user_input step with video content', () => { + const content: Content = { + role: 'user', + parts: [ + { + fileData: { + fileUri: 'gs://bucket/video.mp4', + mimeType: 'video/mp4', + }, + }, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'user_input', + content: [ + { + type: 'video', + uri: 'gs://bucket/video.mp4', + mime_type: 'video/mp4', + } as any, + ], + }, + ]); + }); + + it('should convert file document data to user_input step with document content', () => { + const content: Content = { + role: 'user', + parts: [ + { + fileData: { + fileUri: 'gs://bucket/doc.pdf', + mimeType: 'application/pdf', + }, + }, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'user_input', + content: [ + { + type: 'document', + uri: 'gs://bucket/doc.pdf', + mime_type: 'application/pdf', + } as any, + ], + }, + ]); + }); + + it('should convert inlineData with missing mimeType to document', () => { + const content: Content = { + role: 'user', + parts: [ + { + inlineData: { + data: 'docdata', + }, + }, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'user_input', + content: [ + { + type: 'document', + data: 'docdata', + mime_type: '', + } as any, + ], + }, + ]); + }); + + it('should convert fileData with missing mimeType to document', () => { + const content: Content = { + role: 'user', + parts: [ + { + fileData: { + fileUri: 'gs://bucket/doc.pdf', + }, + }, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'user_input', + content: [ + { + type: 'document', + uri: 'gs://bucket/doc.pdf', + mime_type: '', + } as any, + ], + }, + ]); + }); + + it('should convert codeExecutionResult with missing output', () => { + const content: Content = { + role: 'user', + parts: [ + { + codeExecutionResult: { + outcome: Outcome.OUTCOME_OK, + }, + }, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'code_execution_result', + call_id: '', + result: '', + is_error: false, + }, + ]); + }); + + it('should return empty steps for empty or invalid content', () => { + expect(convertContentToSteps({})).toEqual([]); + expect(convertContentToSteps({parts: []})).toEqual([]); + }); + }); + + describe('convertToolsConfigToInteractionsFormat', () => { + it('should convert function declarations and built-in tools', () => { + const config = { + tools: [ + { + functionDeclarations: [ + { + name: 'tool1', + description: 'desc1', + parameters: { + type: 'OBJECT', + properties: { + param1: {type: 'STRING'}, + }, + required: ['param1'], + }, + }, + ], + }, + {googleSearch: {}}, + {codeExecution: {}}, + ], + }; + + const expected = [ + { + type: 'function', + name: 'tool1', + description: 'desc1', + parameters: { + type: 'object', + properties: { + param1: {type: 'STRING'}, + }, + required: ['param1'], + }, + }, + {type: 'google_search'}, + {type: 'code_execution'}, + ]; + + expect(convertToolsConfigToInteractionsFormat(config as any)).toEqual( + expected, + ); + }); + + it('should convert function declarations without required parameters', () => { + const config = { + tools: [ + { + functionDeclarations: [ + { + name: 'tool1_no_req', + description: 'desc_no_req', + parameters: { + type: 'OBJECT', + properties: { + param1: {type: 'STRING'}, + }, + }, + }, + ], + }, + ], + }; + + const expected = [ + { + type: 'function', + name: 'tool1_no_req', + description: 'desc_no_req', + parameters: { + type: 'object', + properties: { + param1: {type: 'STRING'}, + }, + required: undefined, + }, + }, + ]; + + expect(convertToolsConfigToInteractionsFormat(config as any)).toEqual( + expected, + ); + }); + + it('should convert function declarations with parametersJsonSchema and urlContext', () => { + const config = { + tools: [ + { + functionDeclarations: [ + { + name: 'tool2', + parametersJsonSchema: { + type: 'object', + properties: { + param2: {type: 'string'}, + }, + }, + }, + ], + }, + {urlContext: {}}, + ], + }; + + const expected = [ + { + type: 'function', + name: 'tool2', + parameters: { + type: 'object', + properties: { + param2: {type: 'string'}, + }, + }, + }, + {type: 'url_context'}, + ]; + + expect(convertToolsConfigToInteractionsFormat(config as any)).toEqual( + expected, + ); + }); + }); + + describe('convertInteractionToLlmResponse', () => { + it('should convert successful interaction response', () => { + const interaction = { + id: 'int-123', + status: 'completed', + steps: [ + { + type: 'model_output', + content: [{type: 'text', text: 'Response text'}], + } as Interactions.ModelOutputStep, + ], + usage: { + total_input_tokens: 10, + total_output_tokens: 20, + }, + }; + + const response = convertInteractionToLlmResponse(interaction as any); + + expect(response.interactionId).toBe('int-123'); + expect(response.turnComplete).toBe(true); + expect(response.content?.role).toBe('model'); + expect(response.content?.parts?.[0]?.text).toBe('Response text'); + expect(response.usageMetadata).toEqual({ + promptTokenCount: 10, + candidatesTokenCount: 20, + totalTokenCount: 30, + }); + expect(response.finishReason).toBe('STOP'); + }); + + it('should convert failed interaction response', () => { + const interaction = { + id: 'int-123', + status: 'failed', + error: { + code: 'RESOURCE_EXHAUSTED', + message: 'Quota exceeded', + }, + }; + + const response = convertInteractionToLlmResponse(interaction as any); + + expect(response.interactionId).toBe('int-123'); + expect(response.errorCode).toBe('RESOURCE_EXHAUSTED'); + expect(response.errorMessage).toBe('Quota exceeded'); + }); + + it('should convert failed interaction response with missing error details', () => { + const interaction = { + id: 'int-123', + status: 'failed', + error: {}, + }; + const response = convertInteractionToLlmResponse(interaction as any); + expect(response.errorCode).toBe('UNKNOWN_ERROR'); + expect(response.errorMessage).toBe('Unknown error'); + }); + + it('should handle missing token counts in usage', () => { + const interaction = { + id: 'int-123', + status: 'completed', + usage: {}, + }; + const response = convertInteractionToLlmResponse(interaction as any); + expect(response.usageMetadata).toEqual({ + promptTokenCount: 0, + candidatesTokenCount: 0, + totalTokenCount: 0, + }); + }); + + it('should handle requires_action status', () => { + const interaction = { + id: 'int-123', + status: 'requires_action', + }; + const response = convertInteractionToLlmResponse(interaction as any); + expect(response.turnComplete).toBe(true); + expect(response.finishReason).toBe('STOP'); + }); + }); + + describe('convertInteractionEventToLlmResponse', () => { + it('should handle step.delta text event', () => { + const event = { + event_type: 'step.delta', + delta: { + type: 'text', + text: 'hello', + }, + }; + const aggregatedParts: Part[] = []; + const response = convertInteractionEventToLlmResponse( + event as any, + aggregatedParts, + 'int-1', + ); + + expect(response).toEqual({ + content: {role: 'model', parts: [{text: 'hello'}]}, + partial: true, + turnComplete: false, + interactionId: 'int-1', + }); + expect(aggregatedParts).toEqual([{text: 'hello'}]); + }); + + it('should handle step.start, step.delta (arguments), and step.stop sequence for function call', () => { + const aggregatedParts: Part[] = []; + + // 1. Step Start + const startEvent = { + event_type: 'step.start', + step: { + type: 'function_call', + id: 'call-1', + name: 'my_tool', + }, + }; + let response = convertInteractionEventToLlmResponse( + startEvent as any, + aggregatedParts, + 'int-1', + ); + expect(response).toBeNull(); + expect(aggregatedParts.length).toBe(1); + expect(aggregatedParts[0].functionCall).toEqual({ + id: 'call-1', + name: 'my_tool', + args: {}, + }); + expect(aggregatedParts[0].partMetadata).toEqual({ + accumulatedArgs: '', + isComplete: false, + }); + + // 2. Step Delta (arguments chunk 1) + const deltaEvent1 = { + event_type: 'step.delta', + delta: { + type: 'arguments_delta', + arguments: '{"x":', + }, + }; + response = convertInteractionEventToLlmResponse( + deltaEvent1 as any, + aggregatedParts, + 'int-1', + ); + expect(response).toBeNull(); + expect(aggregatedParts[0].partMetadata?.accumulatedArgs).toBe('{"x":'); + + // 3. Step Delta (arguments chunk 2) + const deltaEvent2 = { + event_type: 'step.delta', + delta: { + type: 'arguments_delta', + arguments: ' 1}', + }, + }; + response = convertInteractionEventToLlmResponse( + deltaEvent2 as any, + aggregatedParts, + 'int-1', + ); + expect(response).toBeNull(); + expect(aggregatedParts[0].partMetadata?.accumulatedArgs).toBe('{"x": 1}'); + + // 4. Step Stop + const stopEvent = { + event_type: 'step.stop', + }; + response = convertInteractionEventToLlmResponse( + stopEvent as any, + aggregatedParts, + 'int-1', + ); + + expect(response).toEqual({ + content: { + role: 'model', + parts: [ + { + functionCall: { + id: 'call-1', + name: 'my_tool', + args: {x: 1}, + }, + }, + ], + }, + partial: false, + turnComplete: false, + interactionId: 'int-1', + }); + expect(aggregatedParts[0].partMetadata).toBeUndefined(); // metadata should be cleaned up + expect(aggregatedParts[0].functionCall?.args).toEqual({x: 1}); + }); + + it('should handle step.start thought event', () => { + const aggregatedParts: Part[] = []; + const event = { + event_type: 'step.start', + step: { + type: 'thought', + signature: 'sig-123', + }, + }; + const response = convertInteractionEventToLlmResponse( + event as any, + aggregatedParts, + 'int-1', + ); + expect(response).toBeNull(); + expect(aggregatedParts).toEqual([ + { + thought: true, + thoughtSignature: 'sig-123', + partMetadata: { + isComplete: false, + }, + }, + ]); + }); + + it('should handle interaction.status_update completed event', () => { + const event = { + event_type: 'interaction.status_update', + status: 'completed', + }; + const aggregatedParts: Part[] = [{text: 'final text'}]; + const response = convertInteractionEventToLlmResponse( + event as any, + aggregatedParts, + 'int-1', + ); + + expect(response).toEqual({ + content: {role: 'model', parts: [{text: 'final text'}]}, + partial: false, + turnComplete: true, + finishReason: 'STOP' as FinishReason, + interactionId: 'int-1', + }); + }); + }); + + describe('generateContentViaInteractions', () => { + it('should handle non-streaming call', async () => { + const mockInteraction = { + id: 'int-999', + status: 'completed', + steps: [ + { + type: 'model_output', + content: [{type: 'text', text: 'Mocked static response'}], + }, + ], + }; + + const mockApiClient = { + interactions: { + create: vi.fn().mockResolvedValue(mockInteraction), + }, + }; + + const llmRequest = { + model: 'gemini-2.5-flash', + contents: [{role: 'user', parts: [{text: 'Hello'}]}], + }; + + const generator = generateContentViaInteractions( + mockApiClient as any, + llmRequest as any, + false, + ); + const responses = []; + for await (const res of generator) { + responses.push(res); + } + + expect(responses.length).toBe(1); + expect(responses[0].content?.parts?.[0]?.text).toBe( + 'Mocked static response', + ); + expect(responses[0].interactionId).toBe('int-999'); + + expect(mockApiClient.interactions.create).toHaveBeenCalledWith({ + model: 'gemini-2.5-flash', + input: [ + { + type: 'user_input', + content: [{type: 'text', text: 'Hello'}], + }, + ], + stream: false, + system_instruction: undefined, + tools: undefined, + generation_config: undefined, + previous_interaction_id: undefined, + }); + }); + + it('should handle streaming call', async () => { + const mockEvents = [ + { + event_type: 'step.start', + step: { + type: 'model_output', + content: [], + }, + interaction_id: 'int-stream', + }, + { + event_type: 'step.delta', + delta: {type: 'text', text: 'Part 1'}, + }, + { + event_type: 'step.delta', + delta: {type: 'text', text: 'Part 2'}, + }, + { + event_type: 'step.stop', + }, + { + event_type: 'interaction.completed', + }, + ]; + + const mockStream = { + [Symbol.asyncIterator]: async function* () { + for (const event of mockEvents) { + yield event; + } + }, + }; + + const mockApiClient = { + interactions: { + create: vi.fn().mockResolvedValue(mockStream), + }, + }; + + const llmRequest = { + model: 'gemini-2.5-flash', + contents: [{role: 'user', parts: [{text: 'Hello stream'}]}], + }; + + const generator = generateContentViaInteractions( + mockApiClient as any, + llmRequest as any, + true, + ); + const responses = []; + for await (const res of generator) { + responses.push(res); + } + + expect(responses.length).toBe(4); + + expect(responses[0]).toEqual({ + content: {role: 'model', parts: [{text: 'Part 1'}]}, + partial: true, + turnComplete: false, + interactionId: 'int-stream', + }); + + expect(responses[1]).toEqual({ + content: {role: 'model', parts: [{text: 'Part 2'}]}, + partial: true, + turnComplete: false, + interactionId: 'int-stream', + }); + + expect(responses[2]).toEqual({ + content: {role: 'model', parts: [{text: 'Part 1'}, {text: 'Part 2'}]}, + partial: false, + turnComplete: true, + finishReason: 'STOP', + interactionId: 'int-stream', + }); + + expect(responses[3]).toEqual({ + content: {role: 'model', parts: [{text: 'Part 1'}, {text: 'Part 2'}]}, + partial: false, + turnComplete: true, + finishReason: 'STOP', + interactionId: 'int-stream', + }); + }); + + it('should trim history when previousInteractionId is present', async () => { + const mockInteraction = { + id: 'int-999', + status: 'completed', + steps: [ + { + type: 'model_output', + content: [{type: 'text', text: 'Mocked response'}], + }, + ], + }; + + const mockApiClient = { + interactions: { + create: vi.fn().mockResolvedValue(mockInteraction), + }, + }; + + const llmRequest = { + model: 'gemini-2.5-flash', + contents: [ + {role: 'user', parts: [{text: 'Turn 1'}]}, + {role: 'model', parts: [{text: 'Reply 1'}]}, + {role: 'user', parts: [{text: 'Turn 2'}]}, + ], + previousInteractionId: 'int-prev', + }; + + const generator = generateContentViaInteractions( + mockApiClient as any, + llmRequest as any, + false, + ); + const responses = []; + for await (const res of generator) { + responses.push(res); + } + + expect(responses.length).toBe(1); + expect(mockApiClient.interactions.create).toHaveBeenCalledWith({ + model: 'gemini-2.5-flash', + input: [ + { + type: 'user_input', + content: [{type: 'text', text: 'Turn 2'}], + }, + ], + stream: false, + system_instruction: undefined, + tools: undefined, + generation_config: undefined, + previous_interaction_id: 'int-prev', + }); + }); + + it('should handle streaming call with interaction event and extract interaction ID', async () => { + const mockEvents = [ + { + event_type: 'step.start', + step: { + type: 'model_output', + content: [], + }, + }, + { + event_type: 'step.delta', + delta: {type: 'text', text: 'Stream text'}, + interaction_id: 'int-from-event', + }, + { + event_type: 'step.stop', + }, + { + event_type: 'interaction.completed', + }, + ]; + + const mockStream = { + [Symbol.asyncIterator]: async function* () { + for (const event of mockEvents) { + yield event; + } + }, + }; + + const mockApiClient = { + interactions: { + create: vi.fn().mockResolvedValue(mockStream), + }, + }; + + const llmRequest = { + model: 'gemini-2.5-flash', + contents: [{role: 'user', parts: [{text: 'Hello'}]}], + }; + + const generator = generateContentViaInteractions( + mockApiClient as any, + llmRequest as any, + true, + ); + const responses = []; + for await (const res of generator) { + responses.push(res); + } + + expect(responses.length).toBe(3); + + expect(responses[0]).toEqual({ + content: {role: 'model', parts: [{text: 'Stream text'}]}, + partial: true, + turnComplete: false, + interactionId: 'int-from-event', + }); + + expect(responses[1].interactionId).toBe('int-from-event'); + expect(responses[1].turnComplete).toBe(true); + + expect(responses[2].interactionId).toBe('int-from-event'); + }); + + it('should pass all generation config parameters', async () => { + const mockInteraction = { + id: 'int-999', + status: 'completed', + steps: [ + { + type: 'model_output', + content: [{type: 'text', text: 'Mocked response'}], + }, + ], + }; + + const mockApiClient = { + interactions: { + create: vi.fn().mockResolvedValue(mockInteraction), + }, + }; + + const llmRequest = { + model: 'gemini-2.5-flash', + contents: [{role: 'user', parts: [{text: 'Hello'}]}], + config: { + temperature: 0.7, + topP: 0.9, + topK: 40, + maxOutputTokens: 100, + stopSequences: ['STOP'], + presencePenalty: 0.5, + frequencyPenalty: 0.5, + tools: [{functionDeclarations: [{name: 'my_tool'}]}], + } as GenerateContentConfig, + }; + + const generator = generateContentViaInteractions( + mockApiClient as any, + llmRequest as any, + false, + ); + for await (const _ of generator) { + // empty + } + + expect(mockApiClient.interactions.create).toHaveBeenCalledWith({ + model: 'gemini-2.5-flash', + input: [ + { + type: 'user_input', + content: [{type: 'text', text: 'Hello'}], + }, + ], + stream: false, + system_instruction: undefined, + tools: [ + { + type: 'function', + name: 'my_tool', + }, + ], + generation_config: { + temperature: 0.7, + top_p: 0.9, + top_k: 40, + max_output_tokens: 100, + stop_sequences: ['STOP'], + presence_penalty: 0.5, + frequency_penalty: 0.5, + }, + previous_interaction_id: undefined, + }); + }); + + it('should pass tools in streaming call', async () => { + const mockStream = { + [Symbol.asyncIterator]: async function* () { + yield { + event_type: 'step.start', + step: { + type: 'model_output', + content: [], + }, + }; + yield { + event_type: 'step.delta', + delta: {type: 'text', text: 'Reply'}, + }; + }, + }; + + const mockApiClient = { + interactions: { + create: vi.fn().mockResolvedValue(mockStream), + }, + }; + + const llmRequest = { + model: 'gemini-2.5-flash', + contents: [{role: 'user', parts: [{text: 'Hello'}]}], + config: { + tools: [{functionDeclarations: [{name: 'my_tool'}]}], + temperature: 0.5, + } as GenerateContentConfig, + }; + + const generator = generateContentViaInteractions( + mockApiClient as any, + llmRequest as any, + true, + ); + for await (const _ of generator) { + // empty + } + + expect(mockApiClient.interactions.create).toHaveBeenCalledWith({ + model: 'gemini-2.5-flash', + input: [ + { + type: 'user_input', + content: [{type: 'text', text: 'Hello'}], + }, + ], + stream: true, + system_instruction: undefined, + tools: [ + { + type: 'function', + name: 'my_tool', + }, + ], + generation_config: { + temperature: 0.5, + }, + previous_interaction_id: undefined, + }); + }); + }); + + describe('convertStepToParts', () => { + it('should return empty array for empty or invalid step', () => { + expect(convertStepToParts(null as any)).toEqual([]); + expect(convertStepToParts({} as any)).toEqual([]); + expect(convertStepToParts({type: 'invalid'} as any)).toEqual([]); + }); + + it('should convert model_output step with text content', () => { + const step = { + type: 'model_output', + content: [{type: 'text', text: 'hello'}], + }; + expect(convertStepToParts(step as any)).toEqual([ + { + text: 'hello', + }, + ]); + }); + + it('should convert model_output step with text content missing text', () => { + const step = { + type: 'model_output', + content: [{type: 'text'}], + }; + expect(convertStepToParts(step as any)).toEqual([ + { + text: '', + }, + ]); + }); + + it('should convert function_call step', () => { + const step = { + type: 'function_call', + id: 'call-1', + name: 'my_tool', + arguments: {a: 1}, + }; + expect(convertStepToParts(step as any)).toEqual([ + { + functionCall: { + id: 'call-1', + name: 'my_tool', + args: {a: 1}, + }, + }, + ]); + }); + + it('should convert function_call step with missing arguments', () => { + const step = { + type: 'function_call', + id: 'call-1', + name: 'my_tool', + }; + expect(convertStepToParts(step as any)).toEqual([ + { + functionCall: { + id: 'call-1', + name: 'my_tool', + args: {}, + }, + }, + ]); + }); + + it('should convert function_call step with signature', () => { + const step = { + type: 'function_call', + id: 'call-1', + name: 'my_tool', + arguments: {a: 1}, + signature: 'sig-123', + }; + expect(convertStepToParts(step as any)).toEqual([ + { + functionCall: { + id: 'call-1', + name: 'my_tool', + args: {a: 1}, + }, + thoughtSignature: 'sig-123', + }, + ]); + }); + + it('should convert function_result step', () => { + const step = { + type: 'function_result', + call_id: 'call-1', + result: {res: 'ok'}, + }; + expect(convertStepToParts(step as any)).toEqual([ + { + functionResponse: { + id: 'call-1', + name: '', + response: {res: 'ok'}, + }, + }, + ]); + }); + + it('should convert model_output step with image content (data)', () => { + const step = { + type: 'model_output', + content: [ + { + type: 'image', + data: 'base64data', + mime_type: 'image/png', + }, + ], + }; + expect(convertStepToParts(step as any)).toEqual([ + { + inlineData: { + data: 'base64data', + mimeType: 'image/png', + }, + }, + ]); + }); + + it('should convert model_output step with image content (uri)', () => { + const step = { + type: 'model_output', + content: [ + { + type: 'image', + uri: 'gs://bucket/img.png', + mime_type: 'image/png', + }, + ], + }; + expect(convertStepToParts(step as any)).toEqual([ + { + fileData: { + fileUri: 'gs://bucket/img.png', + mimeType: 'image/png', + }, + }, + ]); + }); + + it('should convert model_output step with audio content (data)', () => { + const step = { + type: 'model_output', + content: [ + { + type: 'audio', + data: 'base64data', + mime_type: 'audio/mp3', + }, + ], + }; + expect(convertStepToParts(step as any)).toEqual([ + { + inlineData: { + data: 'base64data', + mimeType: 'audio/mp3', + }, + }, + ]); + }); + + it('should convert model_output step with audio content (uri)', () => { + const step = { + type: 'model_output', + content: [ + { + type: 'audio', + uri: 'gs://bucket/audio.mp3', + mime_type: 'audio/mp3', + }, + ], + }; + expect(convertStepToParts(step as any)).toEqual([ + { + fileData: { + fileUri: 'gs://bucket/audio.mp3', + mimeType: 'audio/mp3', + }, + }, + ]); + }); + + it('should convert thought step', () => { + const step = { + type: 'thought', + signature: 'sig-123', + }; + expect(convertStepToParts(step as any)).toEqual([ + { + thought: true, + thoughtSignature: 'sig-123', + }, + ]); + }); + + it('should convert code_execution_result step', () => { + const step = { + type: 'code_execution_result', + result: 'output text', + is_error: false, + }; + expect(convertStepToParts(step as any)).toEqual([ + { + codeExecutionResult: { + output: 'output text', + outcome: Outcome.OUTCOME_OK, + }, + }, + ]); + + const stepError = { + type: 'code_execution_result', + result: 'error text', + is_error: true, + }; + expect(convertStepToParts(stepError as any)).toEqual([ + { + codeExecutionResult: { + output: 'error text', + outcome: Outcome.OUTCOME_FAILED, + }, + }, + ]); + }); + + it('should convert code_execution_result step with missing result', () => { + const step = { + type: 'code_execution_result', + is_error: false, + }; + expect(convertStepToParts(step as any)).toEqual([ + { + codeExecutionResult: { + output: '', + outcome: Outcome.OUTCOME_OK, + }, + }, + ]); + }); + + it('should convert code_execution_call step', () => { + const step = { + type: 'code_execution_call', + arguments: { + code: 'print(1)', + language: 'PYTHON', + }, + }; + expect(convertStepToParts(step as any)).toEqual([ + { + executableCode: { + code: 'print(1)', + language: 'PYTHON', + }, + }, + ]); + }); + + it('should convert code_execution_call step with missing arguments', () => { + const step = { + type: 'code_execution_call', + }; + expect(convertStepToParts(step as any)).toEqual([ + { + executableCode: { + code: '', + language: 'PYTHON', + }, + }, + ]); + }); + }); + + describe('convertInteractionEventToLlmResponse extra cases', () => { + it('should handle step.delta image event (data)', () => { + const event = { + event_type: 'step.delta', + delta: { + type: 'image', + data: 'imgdata', + mime_type: 'image/png', + }, + }; + const aggregatedParts: Part[] = []; + const response = convertInteractionEventToLlmResponse( + event as any, + aggregatedParts, + 'int-1', + ); + expect(response).toEqual({ + content: { + role: 'model', + parts: [ + { + inlineData: { + data: 'imgdata', + mimeType: 'image/png', + }, + }, + ], + }, + partial: false, + turnComplete: false, + interactionId: 'int-1', + }); + }); + + it('should handle step.delta image event (uri)', () => { + const event = { + event_type: 'step.delta', + delta: { + type: 'image', + uri: 'gs://img.png', + mime_type: 'image/png', + }, + }; + const aggregatedParts: Part[] = []; + const response = convertInteractionEventToLlmResponse( + event as any, + aggregatedParts, + 'int-1', + ); + expect(response).toEqual({ + content: { + role: 'model', + parts: [ + { + fileData: { + fileUri: 'gs://img.png', + mimeType: 'image/png', + }, + }, + ], + }, + partial: false, + turnComplete: false, + interactionId: 'int-1', + }); + }); + + it('should handle interaction.status_update failed event', () => { + const event = { + event_type: 'interaction.status_update', + status: 'failed', + error: { + code: 'CANCELLED', + message: 'user cancelled', + }, + }; + const response = convertInteractionEventToLlmResponse( + event as any, + [], + 'int-1', + ); + expect(response).toEqual({ + errorCode: 'CANCELLED', + errorMessage: 'user cancelled', + turnComplete: true, + interactionId: 'int-1', + }); + }); + + it('should handle interaction.status_update failed event with missing error', () => { + const event = { + event_type: 'interaction.status_update', + status: 'failed', + }; + const response = convertInteractionEventToLlmResponse( + event as any, + [], + 'int-1', + ); + expect(response).toEqual({ + errorCode: 'UNKNOWN_ERROR', + errorMessage: 'Unknown error', + turnComplete: true, + interactionId: 'int-1', + }); + }); + + it('should handle interaction.status_update completed event with aggregated parts', () => { + const event = { + event_type: 'interaction.status_update', + status: 'completed', + }; + const parts = [{text: 'part 1'}]; + const response = convertInteractionEventToLlmResponse( + event as any, + parts, + 'int-1', + ); + expect(response).toEqual({ + content: {role: 'model', parts: [{text: 'part 1'}]}, + partial: false, + turnComplete: true, + finishReason: 'STOP', + interactionId: 'int-1', + }); + }); + + it('should handle error event', () => { + const event = { + event_type: 'error', + code: 'INTERNAL', + message: 'internal error', + }; + const response = convertInteractionEventToLlmResponse( + event as any, + [], + 'int-1', + ); + expect(response).toEqual({ + errorCode: 'INTERNAL', + errorMessage: 'internal error', + turnComplete: true, + interactionId: 'int-1', + }); + }); + + it('should handle error event with missing code and message', () => { + const event = { + event_type: 'error', + }; + const response = convertInteractionEventToLlmResponse( + event as any, + [], + 'int-1', + ); + expect(response).toEqual({ + errorCode: 'UNKNOWN_ERROR', + errorMessage: 'Unknown error', + turnComplete: true, + interactionId: 'int-1', + }); + }); + + it('should return null if event.delta is missing in step.delta event', () => { + const event = { + event_type: 'step.delta', + }; + expect(convertInteractionEventToLlmResponse(event as any, [])).toBeNull(); + }); + + it('should handle step.delta thought_signature event', () => { + // 1. Start the function call step + const startEvent = { + event_type: 'step.start', + step: { + type: 'function_call', + id: 'call-1', + name: 'my_tool', + }, + }; + const aggregatedParts: Part[] = []; + convertInteractionEventToLlmResponse(startEvent as any, aggregatedParts); + + expect(aggregatedParts.length).toBe(1); + expect(aggregatedParts[0].functionCall).toBeDefined(); + expect(aggregatedParts[0].thoughtSignature).toBeUndefined(); + + // 2. Stream the signature delta + const deltaEvent = { + event_type: 'step.delta', + delta: { + type: 'thought_signature', + signature: 'my-signature-data', + }, + }; + const response = convertInteractionEventToLlmResponse( + deltaEvent as any, + aggregatedParts, + 'int-1', + ); + + expect(response).toBeNull(); + expect(aggregatedParts[0].thoughtSignature).toBe('my-signature-data'); + }); + + it('should handle event with camelCase eventType', () => { + const event = { + eventType: 'step.delta', + delta: { + type: 'text', + text: 'camelText', + }, + }; + const aggregatedParts: Part[] = []; + const response = convertInteractionEventToLlmResponse( + event as any, + aggregatedParts, + 'int-1', + ); + expect(response?.content?.parts?.[0]?.text).toBe('camelText'); + }); + + it('should handle step.delta text event with missing text', () => { + const event = { + event_type: 'step.delta', + delta: { + type: 'text', + }, + }; + const aggregatedParts: Part[] = []; + const response = convertInteractionEventToLlmResponse( + event as any, + aggregatedParts, + 'int-1', + ); + expect(response).toBeNull(); + }); + + it('should handle interaction.status_update requires_action event', () => { + const event = { + event_type: 'interaction.status_update', + status: 'requires_action', + }; + const response = convertInteractionEventToLlmResponse( + event as any, + [], + 'int-1', + ); + expect(response).toEqual({ + content: undefined, + partial: false, + turnComplete: true, + finishReason: 'STOP', + interactionId: 'int-1', + }); + }); + + it('should return null for unknown event type', () => { + const event = {event_type: 'unknown'}; + expect(convertInteractionEventToLlmResponse(event as any, [])).toBeNull(); + }); + }); + + describe('convertContentToSteps', () => { + it('should convert user Content with text parts to user_input step', () => { + const content: Content = { + role: 'user', + parts: [{text: 'Hello'}, {text: 'World'}], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'user_input', + content: [ + {type: 'text', text: 'Hello'}, + {type: 'text', text: 'World'}, + ], + }, + ]); + }); + + it('should convert user Content with functionResponse to function_result step', () => { + const content: Content = { + role: 'user', + parts: [ + { + functionResponse: { + id: 'call-1', + name: 'my_tool', + response: {result: 'ok'}, + }, + }, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'function_result', + call_id: 'call-1', + name: 'my_tool', + result: {result: 'ok'}, + }, + ]); + }); + + it('should convert user Content with codeExecutionResult to code_execution_result step', () => { + const content: Content = { + role: 'user', + parts: [ + { + codeExecutionResult: { + output: 'compiled output', + outcome: Outcome.OUTCOME_OK, + }, + }, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'code_execution_result', + call_id: '', + result: 'compiled output', + is_error: false, + }, + ]); + }); + + it('should convert model Content with text parts to model_output step', () => { + const content: Content = { + role: 'model', + parts: [{text: 'Hello'}], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'model_output', + content: [{type: 'text', text: 'Hello'}], + }, + ]); + }); + + it('should convert model Content with functionCall to function_call step', () => { + const content: Content = { + role: 'model', + parts: [ + { + functionCall: { + id: 'call-1', + name: 'my_tool', + args: {a: 1}, + }, + thoughtSignature: 'sig-123', + }, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'function_call', + id: 'call-1', + name: 'my_tool', + arguments: {a: 1}, + signature: 'sig-123', + }, + ]); + }); + + it('should convert model Content with executableCode to code_execution_call step', () => { + const content: Content = { + role: 'model', + parts: [ + { + executableCode: { + code: 'print(1)', + language: Language.PYTHON, + }, + }, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'code_execution_call', + id: '', + arguments: { + code: 'print(1)', + language: 'python', + }, + }, + ]); + }); + + it('should convert model Content with thought to thought step', () => { + const content: Content = { + role: 'model', + parts: [ + { + thought: true, + thoughtSignature: 'sig-123', + } as any, + ], + }; + expect(convertContentToSteps(content)).toEqual([ + { + type: 'thought', + signature: 'sig-123', + }, + ]); + }); + }); + + describe('extractSystemInstruction', () => { + it('should return undefined if no systemInstruction', () => { + expect(extractSystemInstruction({})).toBeUndefined(); + }); + + it('should return string instruction directly', () => { + expect(extractSystemInstruction({systemInstruction: 'be helpful'})).toBe( + 'be helpful', + ); + }); + + it('should extract text from Content systemInstruction', () => { + const config = { + systemInstruction: { + role: 'system', + parts: [{text: 'line 1'}, {text: 'line 2'}], + } as Content, + }; + expect(extractSystemInstruction(config)).toBe('line 1\nline 2'); + }); + + it('should return undefined if systemInstruction is object but has no parts', () => { + expect( + extractSystemInstruction({systemInstruction: {} as any}), + ).toBeUndefined(); + }); + + it('should return undefined if Content systemInstruction parts have no text', () => { + const config = { + systemInstruction: { + role: 'system', + parts: [{}], + } as Content, + }; + expect(extractSystemInstruction(config)).toBeUndefined(); + }); + }); + + describe('generateContentViaInteractions extra streaming cases', () => { + it('should handle streaming call with interaction.created event and extract interaction ID from interaction object', async () => { + const mockEvents = [ + { + event_type: 'interaction.created', + interaction: {id: 'int-start-id'}, + }, + { + event_type: 'step.start', + step: { + type: 'model_output', + content: [], + }, + }, + { + event_type: 'step.delta', + delta: {type: 'text', text: 'Stream text'}, + }, + { + event_type: 'step.stop', + }, + { + event_type: 'interaction.completed', + }, + ]; + + const mockStream = { + [Symbol.asyncIterator]: async function* () { + for (const event of mockEvents) { + yield event; + } + }, + }; + + const mockApiClient = { + interactions: { + create: vi.fn().mockResolvedValue(mockStream), + }, + }; + + const llmRequest = { + model: 'gemini-2.5-flash', + contents: [{role: 'user', parts: [{text: 'Hello'}]}], + }; + + const generator = generateContentViaInteractions( + mockApiClient as any, + llmRequest as any, + true, + ); + const responses = []; + for await (const res of generator) { + responses.push(res); + } + + expect(responses.length).toBe(3); // delta, completed, end-of-generator + + expect(responses[0]).toEqual({ + content: {role: 'model', parts: [{text: 'Stream text'}]}, + partial: true, + turnComplete: false, + interactionId: 'int-start-id', + }); + + expect(responses[1].interactionId).toBe('int-start-id'); + expect(responses[1].turnComplete).toBe(true); + }); + + it('should extract interaction ID from interactionId (camelCase) in streaming event', async () => { + const mockEvents = [ + { + event_type: 'step.start', + step: { + type: 'model_output', + content: [], + }, + interactionId: 'int-camel-case', + }, + { + event_type: 'step.delta', + delta: {type: 'text', text: 'Reply'}, + }, + ]; + const mockStream = { + [Symbol.asyncIterator]: async function* () { + for (const event of mockEvents) { + yield event; + } + }, + }; + const mockApiClient = { + interactions: { + create: vi.fn().mockResolvedValue(mockStream), + }, + }; + const llmRequest = { + model: 'gemini-2.5-flash', + contents: [{role: 'user', parts: [{text: 'Hello'}]}], + }; + + const generator = generateContentViaInteractions( + mockApiClient as any, + llmRequest as any, + true, + ); + const responses = []; + for await (const res of generator) { + responses.push(res); + } + expect(responses[0].interactionId).toBe('int-camel-case'); + }); + }); +}); diff --git a/dev/test/utils/agent_loader_test.ts b/dev/test/utils/agent_loader_test.ts index e0fc3cf0..ca62d5ec 100644 --- a/dev/test/utils/agent_loader_test.ts +++ b/dev/test/utils/agent_loader_test.ts @@ -4,13 +4,21 @@ * SPDX-License-Identifier: Apache-2.0 */ import esbuild from 'esbuild'; -import {exec} from 'node:child_process'; import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; import {pathToFileURL} from 'node:url'; -import {promisify} from 'node:util'; -import {afterEach, beforeEach, describe, expect, it, Mock, vi} from 'vitest'; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + Mock, + vi, +} from 'vitest'; import { AgentFile, @@ -19,8 +27,6 @@ import { } from '../../src/utils/agent_loader.js'; import * as fileUtils from '../../src/utils/file_utils.js'; -const execAsync = promisify(exec); - vi.mock('../../src/utils/file_utils.js', () => ({ getTempDir: vi.fn(), isFile: vi.fn(), @@ -114,13 +120,22 @@ describe('AgentLoader', () => { const compiledPath = (fileName: string) => path.join(tempLoaderDir, fileName); - beforeEach(async () => { + beforeAll(async () => { tempAgentsDir = await fs.mkdtemp( path.join(os.tmpdir(), 'agent-loader-test'), ); tempLoaderDir = await fs.mkdtemp( path.join(os.tmpdir(), 'agent-loader-output-test'), ); + await initNpmProject(); + }, 60000); + + afterAll(async () => { + await fs.rm(tempAgentsDir, {recursive: true, force: true}); + await fs.rm(tempLoaderDir, {recursive: true, force: true}); + }); + + beforeEach(async () => { (fileUtils.getTempDir as Mock).mockImplementation(() => tempLoaderDir); (fileUtils.isFileExists as Mock).mockImplementation(() => true); (fileUtils.isFolderExists as Mock).mockImplementation( @@ -139,12 +154,35 @@ describe('AgentLoader', () => { (fileUtils.tryToFindFileRecursively as Mock).mockImplementation( async (_sourceFolder, fileName) => path.join(tempAgentsDir, fileName), ); - await initNpmProject(); }); afterEach(async () => { - await fs.rm(tempAgentsDir, {recursive: true, force: true}); - await fs.rm(tempLoaderDir, {recursive: true, force: true}); + try { + const files = await fs.readdir(tempAgentsDir); + for (const file of files) { + if (file !== 'package.json' && file !== 'node_modules') { + await fs.rm(path.join(tempAgentsDir, file), { + recursive: true, + force: true, + }); + } + } + } catch { + // ignore + } + + try { + const files = await fs.readdir(tempLoaderDir); + for (const file of files) { + await fs.rm(path.join(tempLoaderDir, file), { + recursive: true, + force: true, + }); + } + } catch { + // ignore + } + vi.clearAllMocks(); }); @@ -154,13 +192,18 @@ describe('AgentLoader', () => { JSON.stringify({ name: 'test-agents', version: '1.0.0', - dependencies: { - '@google/adk': `file:${path.dirname(require.resolve('@google/adk'))}`, - }, }), ); - await execAsync('npm install', {cwd: tempAgentsDir}); + const adkPath = path.resolve( + path.dirname(require.resolve('@google/adk')), + '..', + '..', + ); + const nodeModulesDir = path.join(tempAgentsDir, 'node_modules'); + const googleDir = path.join(nodeModulesDir, '@google'); + await fs.mkdir(googleDir, {recursive: true}); + await fs.symlink(adkPath, path.join(googleDir, 'adk'), 'dir'); } describe('AgentFile', () => { diff --git a/package-lock.json b/package-lock.json index 6295d47e..a349c23c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,7 +47,7 @@ "@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0", "@google-cloud/storage": "^7.17.1", "@google-cloud/vertexai": "^1.12.0", - "@google/genai": "^1.37.0", + "@google/genai": "^2.0.0", "@mikro-orm/core": "^6.6.10", "@mikro-orm/reflection": "^6.6.6", "@modelcontextprotocol/sdk": "^1.26.0", @@ -85,6 +85,30 @@ "@mikro-orm/sqlite": "^6.6.6" } }, + "core/node_modules/@google/genai": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.5.0.tgz", + "integrity": "sha512-qDi3LLh9I3llJK0f9uV8kZ8EdT9oHPxGJJ9yOJ/i5YXYrVwRCs8jHo9x4e99uOeKYDvD3TZwT70p/H/LS3BixQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, "dev": { "name": "@google/adk-devtools", "version": "1.2.0",