@juspay/neurolink
Version:
Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio
956 lines (955 loc) • 41.6 kB
JavaScript
/**
* Shared utilities for Gemini 3 native SDK support.
*
* Both GoogleAIStudioProvider and GoogleVertexProvider route Gemini 3 models
* with tools to the native @google/genai SDK (bypassing the Vercel AI SDK)
* in order to properly handle thought_signature in multi-turn tool calling.
*
* This module extracts the functions that are duplicated between the two
* providers so they can share a single implementation.
*/
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { extname } from "node:path";
import { jsonSchema as aiJsonSchema, tool as createAISDKTool, } from "ai";
import { DEFAULT_MAX_STEPS, DEFAULT_TOOL_MAX_RETRIES, } from "../core/constants.js";
import { logger } from "../utils/logger.js";
import { convertZodToJsonSchema, ensureNestedSchemaTypes, inlineJsonSchema, isZodSchema, normalizeJsonSchemaObject, } from "../utils/schemaConversion.js";
import { createNativeThinkingConfig } from "../utils/thinkingConfig.js";
// ── Functions ──
/**
* Google's `function_declarations[].name` validator regex.
*
* Empirically (and per the Vertex/AI Studio API error message), the server
* enforces `[A-Za-z_][A-Za-z0-9_.:-]{0,127}`. Tool names that don't match
* fail with HTTP 400 "Invalid function name", which surfaces as a misleading
* tool-calling failure for the whole request.
*
* MCP-imported or user-registered tools may legally contain characters
* outside this set (e.g. `/`, spaces, unicode), so we sanitize defensively
* before sending to Google. The sanitized name is also used as the
* `executeMap` key so the round-trip from Google's function-call response
* back to our executor still works.
*/
const GOOGLE_FN_NAME_REGEX = /^[A-Za-z_][A-Za-z0-9_.:-]{0,127}$/;
const GOOGLE_FN_NAME_MAX_LENGTH = 128;
export function sanitizeForGoogleFunctionName(name) {
if (GOOGLE_FN_NAME_REGEX.test(name)) {
return name;
}
let sanitized = name.replace(/[^A-Za-z0-9_.:-]/g, "_");
if (!/^[A-Za-z_]/.test(sanitized)) {
sanitized = `_${sanitized}`;
}
if (sanitized.length > GOOGLE_FN_NAME_MAX_LENGTH) {
sanitized = sanitized.slice(0, GOOGLE_FN_NAME_MAX_LENGTH);
}
return sanitized;
}
/**
* Resolve a sanitized Gemini tool name to one that is both unique within
* the current request and at most 128 characters. When the candidate
* collides with an already-used name we append `_2`, `_3`, … — but
* reserve room for the suffix by truncating the base first so the
* resolved name never exceeds Google's `function_declarations[].name`
* limit.
*
* @param base The already-sanitized candidate name.
* @param isTaken Predicate that returns true if `name` is already used.
*/
export function resolveUniqueGoogleFunctionName(base, isTaken) {
if (!isTaken(base)) {
return base;
}
let suffix = 2;
while (true) {
const suffixStr = `_${suffix}`;
const trimmedBase = base.slice(0, GOOGLE_FN_NAME_MAX_LENGTH - suffixStr.length);
const candidate = `${trimmedBase}${suffixStr}`;
if (!isTaken(candidate)) {
return candidate;
}
suffix++;
}
}
/**
* Sanitize a JSON Schema for Gemini's proto-based API.
*
* Gemini cannot handle `anyOf`/`oneOf` union types in function declarations
* because its proto format expects a single `type` field, not a list of types.
* This function recursively converts unions to `string` type (the most
* permissive primitive that can represent any value as text).
*
* Also removes `$schema`, `additionalProperties`, and `default` keys that
* Gemini's proto format doesn't support.
*/
export function sanitizeSchemaForGemini(schema) {
// If this node has anyOf/oneOf, collapse to string type
if (Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf)) {
const unionKey = schema.anyOf ? "anyOf" : "oneOf";
const variants = schema[unionKey];
// Check if it's a nullable union (e.g., anyOf: [{type: "string"}, {type: "null"}])
const nonNullVariants = variants.filter((v) => v.type !== "null" && v.type !== "undefined");
if (nonNullVariants.length === 1) {
// Simple nullable — use the non-null type with nullable flag
const base = sanitizeSchemaForGemini({ ...nonNullVariants[0] });
base.nullable = true;
if (schema.description) {
base.description = schema.description;
}
return base;
}
// Multi-type union — collapse to string with description noting the original types
const types = nonNullVariants.map((v) => v.type || "unknown").join(" | ");
const result = { type: "string" };
const desc = schema.description
? `${schema.description} (accepts: ${types})`
: `Value as string (accepts: ${types})`;
result.description = desc;
if (variants.some((v) => v.type === "null")) {
result.nullable = true;
}
return result;
}
const result = {};
for (const [key, value] of Object.entries(schema)) {
// Skip keys unsupported by Gemini proto format
if (key === "$schema" ||
key === "additionalProperties" ||
key === "default") {
continue;
}
if (key === "properties" && value && typeof value === "object") {
const properties = {};
for (const [propName, propSchema] of Object.entries(value)) {
if (propSchema && typeof propSchema === "object") {
properties[propName] = sanitizeSchemaForGemini(propSchema);
}
else {
properties[propName] = propSchema;
}
}
result[key] = properties;
}
else if (key === "items" && value && typeof value === "object") {
if (Array.isArray(value)) {
result[key] = value.map((item) => item && typeof item === "object"
? sanitizeSchemaForGemini(item)
: item);
}
else {
result[key] = sanitizeSchemaForGemini(value);
}
}
else {
result[key] = value;
}
}
// Recurse through composed schema branches
if (Array.isArray(result.allOf)) {
result.allOf = result.allOf.map((s) => sanitizeSchemaForGemini(s));
}
if (result.not && typeof result.not === "object") {
result.not = sanitizeSchemaForGemini(result.not);
}
for (const branch of ["if", "then", "else"]) {
if (result[branch] && typeof result[branch] === "object") {
result[branch] = sanitizeSchemaForGemini(result[branch]);
}
}
// JSON Schema Draft-4 `exclusiveMinimum: true` / `exclusiveMaximum: true`
// (boolean form) is rejected by Gemini's OpenAPI 3.0 validator, which
// expects a numeric bound. zod-to-json-schema's openApi3 target still
// emits the Draft-4 form for `z.number().positive()` etc. Translate to
// the numeric form when paired with `minimum`/`maximum`, or drop.
if (typeof result.exclusiveMinimum === "boolean") {
if (result.exclusiveMinimum === true &&
typeof result.minimum === "number") {
result.exclusiveMinimum = result.minimum;
delete result.minimum;
}
else {
delete result.exclusiveMinimum;
}
}
if (typeof result.exclusiveMaximum === "boolean") {
if (result.exclusiveMaximum === true &&
typeof result.maximum === "number") {
result.exclusiveMaximum = result.maximum;
delete result.maximum;
}
else {
delete result.exclusiveMaximum;
}
}
// Clamp `maximum`/`minimum` past int32 — Gemini's protobuf serializer
// treats `type: "integer"` as int32 and rejects bounds beyond ~2.1e9.
const INT32_MAX = 2147483647;
if (typeof result.maximum === "number" && result.maximum > INT32_MAX) {
delete result.maximum;
}
if (typeof result.minimum === "number" && result.minimum < -INT32_MAX) {
delete result.minimum;
}
return result;
}
/**
* Sanitize Vercel AI SDK tools for Gemini compatibility.
*
* For the Vercel AI SDK path (non-native), tool parameters are Zod schemas that
* get converted to JSON Schema internally by @ai-sdk/google. This conversion
* doesn't sanitize union types (anyOf/oneOf), causing Gemini proto errors.
*
* This function pre-converts each tool's Zod parameters to sanitized JSON Schema
* and re-wraps with the Vercel AI SDK's jsonSchema() helper.
*/
export function sanitizeToolsForGemini(tools) {
const sanitized = {};
const dropped = [];
const renamed = [];
const originalNameMap = new Map();
for (const [name, tool] of Object.entries(tools)) {
try {
// Sanitize the tool name to fit Google's function_declarations regex.
// Without this, MCP-imported or user-registered tools whose names contain
// characters outside [A-Za-z_][A-Za-z0-9_.:-]{0,127} cause the entire
// request to 400 with "Invalid function name", surfacing as a misleading
// tool-calling failure. Distinct originals that collapse onto the same
// sanitized name (e.g. "my/tool" and "my-tool" → "my_tool") are
// disambiguated with a numeric suffix that preserves Google's 128-char
// ceiling.
const candidate = sanitizeForGoogleFunctionName(name);
const safeName = resolveUniqueGoogleFunctionName(candidate, (n) => n in sanitized);
// Always record the mapping so downstream code can translate every
// safeName back to the original — including the no-rename identity
// mapping, which simplifies the lookup path.
originalNameMap.set(safeName, name);
if (safeName !== name) {
renamed.push({ from: name, to: safeName });
}
// Access the legacy `parameters` field that may exist on older AI SDK tools.
// AI SDK v6 uses `inputSchema`, but v3/v4 tools and third-party wrappers use `parameters`.
const legacyTool = tool;
const params = legacyTool.parameters;
if (params &&
typeof params === "object" &&
"_def" in params &&
typeof params.parse === "function") {
const rawJsonSchema = convertZodToJsonSchema(params, "openApi3");
const inlined = inlineJsonSchema(rawJsonSchema);
// Gemini sanitization strips Zod-only features not supported by the Gemini API:
// union types (anyOf/oneOf) are collapsed to string, default values and
// additionalProperties are removed. The resulting schema is Gemini-compatible
// but loses some type constraints from the original Zod schema.
const sanitizedSchema = sanitizeSchemaForGemini(inlined);
sanitized[safeName] = createAISDKTool({
description: tool.description || `Tool: ${safeName}`,
inputSchema: aiJsonSchema(sanitizedSchema),
execute: tool.execute,
});
}
else if (params &&
typeof params === "object" &&
"jsonSchema" in params) {
// Non-Zod JSON schema (e.g., from ai SDK jsonSchema() helper) — still needs sanitization
const rawSchema = params
.jsonSchema;
const sanitizedSchema = sanitizeSchemaForGemini(inlineJsonSchema(rawSchema));
sanitized[safeName] = createAISDKTool({
description: tool.description || `Tool: ${safeName}`,
inputSchema: aiJsonSchema(sanitizedSchema),
execute: tool.execute,
});
}
else {
sanitized[safeName] = tool;
}
}
catch (error) {
logger.warn(`[Gemini] Failed to sanitize tool "${name}", skipping: ${error instanceof Error ? error.message : String(error)}`);
// Don't fall back to the original tool — an incompatible schema would fail the Gemini request
dropped.push(name);
}
}
if (renamed.length > 0) {
logger.warn(`[Gemini] ${renamed.length} tool name(s) sanitized for Google's function-name regex: ${renamed
.map((r) => `"${r.from}" -> "${r.to}"`)
.join(", ")}`);
}
return { tools: sanitized, dropped, originalNameMap };
}
export function normalizeToolsForJsonSchemaProvider(tools) {
const normalizedTools = {};
const normalized = [];
for (const [name, tool] of Object.entries(tools)) {
const legacyTool = tool;
const toolParams = legacyTool.parameters || tool.inputSchema;
let rawSchema;
if (isZodSchema(toolParams)) {
rawSchema = convertZodToJsonSchema(toolParams, "openApi3");
}
else if (toolParams && typeof toolParams === "object") {
rawSchema = toolParams;
}
else {
rawSchema = { type: "object", properties: {} };
}
if (rawSchema.jsonSchema &&
typeof rawSchema.jsonSchema === "object" &&
!rawSchema.type) {
rawSchema = rawSchema.jsonSchema;
}
const schemaBefore = JSON.stringify(rawSchema);
const normalizedSchema = normalizeJsonSchemaObject(rawSchema);
if (JSON.stringify(normalizedSchema) !== schemaBefore) {
normalized.push(name);
}
const wrappedSchema = aiJsonSchema(normalizedSchema);
normalizedTools[name] = {
...tool,
inputSchema: wrappedSchema,
...(legacyTool.parameters ? { parameters: wrappedSchema } : {}),
};
}
return {
tools: normalizedTools,
normalized,
};
}
/**
* Convert Vercel AI SDK tools to @google/genai FunctionDeclarations and an execute map.
*
* This handles both Zod schemas and plain JSON Schema objects for tool parameters.
*/
export function buildNativeToolDeclarations(tools) {
const functionDeclarations = [];
const executeMap = new Map();
const skippedTools = [];
const renamedTools = [];
// Disambiguate distinct originals that collapse onto the same sanitized
// name (e.g. "my/tool" and "my-tool" both → "my_tool") via
// resolveUniqueGoogleFunctionName, which appends `_N` while keeping the
// final string within Google's 128-char limit. Track all assigned names
// regardless of whether the tool has an `execute` function (tools without
// execute are still pushed to functionDeclarations). The originalNameMap
// lets the calling stream loop translate Google-returned function-call
// names back to the consumer-facing identifier so the sanitization is
// transport-only.
const usedNames = new Set();
const originalNameMap = new Map();
for (const [name, tool] of Object.entries(tools)) {
try {
const candidate = sanitizeForGoogleFunctionName(name);
const safeName = resolveUniqueGoogleFunctionName(candidate, (n) => usedNames.has(n));
originalNameMap.set(safeName, name);
if (safeName !== name) {
renamedTools.push({ from: name, to: safeName });
}
const decl = {
name: safeName,
description: tool.description || `Tool: ${safeName}`,
};
// Access legacy `parameters` (AI SDK v3/v4) or current `inputSchema` (v6)
const legacyTool = tool;
if (legacyTool.parameters || tool.inputSchema) {
let rawSchema;
const toolParams = legacyTool.parameters || tool.inputSchema;
if (isZodSchema(toolParams)) {
rawSchema = convertZodToJsonSchema(toolParams, "openApi3");
}
else if (typeof toolParams === "object") {
rawSchema = toolParams;
}
else {
rawSchema = { type: "object", properties: {} };
}
// Unwrap Vercel AI SDK's jsonSchema() wrapper: { jsonSchema: { type: "object", ... } }
if (rawSchema.jsonSchema &&
typeof rawSchema.jsonSchema === "object" &&
!rawSchema.type) {
rawSchema = rawSchema.jsonSchema;
}
decl.parametersJsonSchema = sanitizeSchemaForGemini(inlineJsonSchema(rawSchema));
}
functionDeclarations.push(decl);
usedNames.add(safeName);
if (tool.execute) {
executeMap.set(decl.name, tool.execute);
}
}
catch (err) {
skippedTools.push(name);
logger.error(`[buildNativeToolDeclarations] Failed to convert tool "${name}":`, err);
}
}
if (skippedTools.length > 0) {
logger.warn(`[buildNativeToolDeclarations] ${skippedTools.length} tool(s) skipped due to schema errors: ${skippedTools.join(", ")}`);
}
if (renamedTools.length > 0) {
logger.warn(`[buildNativeToolDeclarations] ${renamedTools.length} tool name(s) sanitized for Google's function-name regex: ${renamedTools
.map((r) => `"${r.from}" -> "${r.to}"`)
.join(", ")}`);
}
return {
toolsConfig: [{ functionDeclarations }],
executeMap,
originalNameMap,
};
}
/**
* Build the native @google/genai config object shared by stream and generate.
*
* Caller is responsible for the tools-vs-JSON conflict resolution: Gemini's
* function calling cannot be combined with `responseMimeType:
* "application/json"`, and `responseSchema` requires that mime type. So
* when tools are active, callers must NOT pass `wantsJsonOutput`/
* `responseSchema` here; when JSON/schema output is requested, callers
* must omit `toolsConfig`. The AI Studio path enforces this by forcing
* `disableTools: true` whenever JSON/schema output is requested.
*/
export function buildNativeConfig(options, toolsConfig) {
const config = {
temperature: options.temperature ?? 1.0, // Gemini 3 requires 1.0 for tool calling
maxOutputTokens: options.maxTokens,
};
if (toolsConfig) {
config.tools = toolsConfig;
}
if (options.systemPrompt) {
config.systemInstruction = options.systemPrompt;
}
// Add thinking config for Gemini 3
const nativeThinkingConfig = createNativeThinkingConfig(options.thinkingConfig);
if (nativeThinkingConfig) {
config.thinkingConfig = nativeThinkingConfig;
}
// Native JSON / schema enforcement. Only set when tools are NOT being sent
// (Gemini rejects the combination). responseSchema implies JSON mime type.
if (!toolsConfig) {
if (options.responseSchema || options.wantsJsonOutput) {
config.responseMimeType = "application/json";
}
if (options.responseSchema) {
config.responseSchema = options.responseSchema;
}
}
return config;
}
/**
* Safety cap for native Gemini 3 SDK agentic tool-calling loops.
* Lower than DEFAULT_MAX_STEPS (200) to prevent runaway iterations
* in the native SDK path which bypasses Vercel AI SDK step limits.
*/
const GEMINI3_NATIVE_MAX_STEPS = 100;
/**
* Compute a safe, clamped maxSteps value.
*/
export function computeMaxSteps(rawMaxSteps) {
const value = rawMaxSteps || DEFAULT_MAX_STEPS;
return Number.isFinite(value) && value > 0
? Math.min(Math.floor(value), GEMINI3_NATIVE_MAX_STEPS)
: Math.min(DEFAULT_MAX_STEPS, GEMINI3_NATIVE_MAX_STEPS);
}
/**
* Process stream chunks to extract raw response parts, function calls, and usage metadata.
*
* Consumes the full async iterable and returns all collected data.
*/
export async function collectStreamChunks(stream) {
const rawResponseParts = [];
const stepFunctionCalls = [];
let inputTokens = 0;
let outputTokens = 0;
for await (const chunk of stream) {
// Extract raw parts from candidates FIRST
// This avoids using chunk.text which triggers SDK warning when
// non-text parts (thoughtSignature, functionCall) are present
const chunkRecord = chunk;
const candidates = chunkRecord.candidates;
const firstCandidate = candidates?.[0];
const chunkContent = firstCandidate?.content;
if (chunkContent && Array.isArray(chunkContent.parts)) {
rawResponseParts.push(...chunkContent.parts);
}
if (chunk.functionCalls) {
stepFunctionCalls.push(...chunk.functionCalls);
}
// Accumulate usage metadata from chunks
const usage = chunkRecord.usageMetadata;
if (usage) {
inputTokens = Math.max(inputTokens, usage.promptTokenCount || 0);
outputTokens = Math.max(outputTokens, usage.candidatesTokenCount || 0);
}
}
return { rawResponseParts, stepFunctionCalls, inputTokens, outputTokens };
}
/**
* Create a push-based text channel that bridges a background producer
* (the agentic tool-calling loop) with an async-iterable consumer.
*
* This enables truly incremental streaming: text parts are yielded to the
* caller as they arrive from the network, rather than being buffered until
* the model finishes generating.
*/
export function createTextChannel() {
const queue = [];
let done = false;
let fatalError = undefined;
// Resolve the current "wait for data" promise when new data arrives
let notify = null;
function wake() {
if (notify) {
const fn = notify;
notify = null;
fn();
}
}
function push(text) {
if (done) {
return;
}
queue.push({ content: text });
wake();
}
function close() {
done = true;
wake();
}
function error(err) {
done = true;
fatalError = err;
wake();
}
let readIndex = 0;
async function* iterable() {
try {
while (true) {
if (readIndex < queue.length) {
yield queue[readIndex++];
// Periodically compact consumed chunks to avoid unbounded retention
if (readIndex > 1024 && readIndex * 2 >= queue.length) {
queue.splice(0, readIndex);
readIndex = 0;
}
}
else if (done) {
if (fatalError !== undefined) {
throw fatalError instanceof Error
? fatalError
: new Error(String(fatalError));
}
return;
}
else {
// Wait until the producer pushes data or signals completion
await new Promise((resolve) => {
notify = resolve;
});
}
}
}
finally {
// Consumer stopped reading (e.g. disconnect/cancel): stop buffering.
done = true;
queue.length = 0;
notify?.();
}
}
return { push, close, error, iterable: iterable() };
}
/**
* Iterate a single stream step incrementally, pushing text parts to `channel`
* as they arrive from the network while simultaneously accumulating the full
* `CollectedChunkResult` needed for history and token accounting.
*
* Used for all steps (both intermediate tool-calling steps and the final
* text-only step). Text parts are pushed to the channel as they arrive,
* enabling truly incremental streaming. The complete `rawResponseParts`
* (including thoughtSignature) are still returned at the end for use by
* `pushModelResponseToHistory`.
*/
export async function collectStreamChunksIncremental(stream, channel) {
const rawResponseParts = [];
const stepFunctionCalls = [];
let inputTokens = 0;
let outputTokens = 0;
for await (const chunk of stream) {
const chunkRecord = chunk;
const candidates = chunkRecord.candidates;
const firstCandidate = candidates?.[0];
const chunkContent = firstCandidate?.content;
if (chunkContent && Array.isArray(chunkContent.parts)) {
for (const part of chunkContent.parts) {
rawResponseParts.push(part);
// Forward text parts to the consumer immediately
if (typeof part.text === "string" && part.text.length > 0) {
channel.push(part.text);
}
}
}
if (chunk.functionCalls) {
stepFunctionCalls.push(...chunk.functionCalls);
}
const usage = chunkRecord.usageMetadata;
if (usage) {
inputTokens = Math.max(inputTokens, usage.promptTokenCount || 0);
outputTokens = Math.max(outputTokens, usage.candidatesTokenCount || 0);
}
}
return { rawResponseParts, stepFunctionCalls, inputTokens, outputTokens };
}
/**
* Extract the thoughtSignature token from raw response parts.
* Returns the last thoughtSignature found (each step may produce one).
*/
export function extractThoughtSignature(rawResponseParts) {
for (let i = rawResponseParts.length - 1; i >= 0; i--) {
const part = rawResponseParts[i];
if (part !== null &&
part !== undefined &&
typeof part === "object" &&
"thoughtSignature" in part &&
typeof part.thoughtSignature ===
"string") {
return part.thoughtSignature;
}
}
return undefined;
}
/**
* Extract text from raw response parts, filtering out non-text parts
* (thoughtSignature, functionCall) to avoid SDK warnings.
*/
export function extractTextFromParts(rawResponseParts) {
return rawResponseParts
.filter((part) => typeof part.text === "string")
.map((part) => part.text)
.join("");
}
/**
* Execute a batch of native function calls with retry tracking and permanent failure detection.
*
* @param logLabel - Label for log messages (e.g. "[GoogleAIStudio]" or "[GoogleVertex]")
* @param stepFunctionCalls - The function calls from the model
* @param executeMap - Map of tool name to execute function
* @param failedTools - Mutable map tracking per-tool failure counts
* @param allToolCalls - Mutable array accumulating all tool call records
* @param options - Optional settings for execution tracking and cancellation,
* plus an `originalNameMap` (Google-safe → consumer-supplied
* identifier) so the sanitization stays transport-only and
* consumers see the names they registered.
* @returns Array of function responses for conversation history
*/
export async function executeNativeToolCalls(logLabel, stepFunctionCalls, executeMap, failedTools, allToolCalls, options) {
const functionResponses = [];
// Translate a Google-safe sanitized name back to the consumer-facing
// original name. Falls back to the safe name if the map is missing or
// doesn't contain the call (e.g. tool added mid-conversation).
const externalName = (safeName) => options?.originalNameMap?.get(safeName) ?? safeName;
for (const call of stepFunctionCalls) {
const exposedName = externalName(call.name);
allToolCalls.push({ toolName: exposedName, args: call.args });
// Check if this tool has already exceeded retry limit
const failedInfo = failedTools.get(call.name);
if (failedInfo && failedInfo.count >= DEFAULT_TOOL_MAX_RETRIES) {
logger.warn(`${logLabel} Tool "${exposedName}" has exceeded retry limit (${DEFAULT_TOOL_MAX_RETRIES}), skipping execution`);
const errorOutput = {
error: `TOOL_PERMANENTLY_FAILED: The tool "${exposedName}" has failed ${failedInfo.count} times and will not be retried. Last error: ${failedInfo.lastError}. Please proceed without using this tool or inform the user that this functionality is unavailable.`,
status: "permanently_failed",
do_not_retry: true,
};
// Wire transport-side `name: call.name` (Google needs the sanitized
// form to match the function declaration) while exposing the
// consumer-facing name in toolExecutions metadata.
functionResponses.push({
functionResponse: { name: call.name, response: errorOutput },
});
options?.toolExecutions?.push({
name: exposedName,
input: call.args,
output: errorOutput,
});
continue;
}
const execute = executeMap.get(call.name);
if (execute) {
try {
// AI SDK Tool execute requires (args, options) - provide minimal options
// Use randomUUID to avoid toolCallId collisions across concurrent calls
const toolOptions = {
toolCallId: `${call.name}-${randomUUID()}`,
messages: [],
abortSignal: options?.abortSignal,
};
const result = await execute(call.args, toolOptions);
functionResponses.push({
functionResponse: { name: call.name, response: { result } },
});
options?.toolExecutions?.push({
name: exposedName,
input: call.args,
output: result,
});
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
// Track this failure
const currentFailInfo = failedTools.get(call.name) || {
count: 0,
lastError: "",
};
currentFailInfo.count++;
currentFailInfo.lastError = errorMessage;
failedTools.set(call.name, currentFailInfo);
logger.warn(`${logLabel} Tool "${exposedName}" failed (attempt ${currentFailInfo.count}/${DEFAULT_TOOL_MAX_RETRIES}): ${errorMessage}`);
// Determine if this is a permanent failure
const isPermanentFailure = currentFailInfo.count >= DEFAULT_TOOL_MAX_RETRIES;
const errorOutput = {
error: isPermanentFailure
? `TOOL_PERMANENTLY_FAILED: The tool "${exposedName}" has failed ${currentFailInfo.count} times with error: ${errorMessage}. This tool will not be retried. Please proceed without using this tool or inform the user that this functionality is unavailable.`
: `TOOL_EXECUTION_ERROR: ${errorMessage}. Retry attempt ${currentFailInfo.count}/${DEFAULT_TOOL_MAX_RETRIES}.`,
status: isPermanentFailure ? "permanently_failed" : "failed",
do_not_retry: isPermanentFailure,
retry_count: currentFailInfo.count,
max_retries: DEFAULT_TOOL_MAX_RETRIES,
};
functionResponses.push({
functionResponse: { name: call.name, response: errorOutput },
});
options?.toolExecutions?.push({
name: exposedName,
input: call.args,
output: errorOutput,
});
}
}
else {
// Tool not found is a permanent error
const errorOutput = {
error: `TOOL_NOT_FOUND: The tool "${exposedName}" does not exist. Do not attempt to call this tool again.`,
status: "permanently_failed",
do_not_retry: true,
};
functionResponses.push({
functionResponse: { name: call.name, response: errorOutput },
});
options?.toolExecutions?.push({
name: exposedName,
input: call.args,
output: errorOutput,
});
}
}
return functionResponses;
}
/**
* Handle maxSteps termination by producing a final text when the model
* was still calling tools when the step limit was reached.
*
* @param logLabel - Label for log messages (e.g. "[GoogleAIStudio]" or "[GoogleVertex]")
*/
export function handleMaxStepsTermination(logLabel, step, maxSteps, finalText, lastStepText) {
if (step >= maxSteps && !finalText) {
logger.warn(`${logLabel} Tool call loop terminated after reaching maxSteps (${maxSteps}). ` +
`Model was still calling tools. Using accumulated text from last step.`);
return (lastStepText ||
`[Tool execution limit reached after ${maxSteps} steps. The model continued requesting tool calls beyond the limit.]`);
}
return finalText;
}
/**
* Push model response parts to conversation history, preserving thoughtSignature
* for Gemini 3 multi-turn tool calling.
*/
export function pushModelResponseToHistory(currentContents, rawResponseParts, stepFunctionCalls) {
currentContents.push({
role: "model",
parts: rawResponseParts.length > 0
? rawResponseParts
: stepFunctionCalls.map((fc) => ({ functionCall: fc })),
});
}
/**
* Convert a Zod schema (or AI SDK `jsonSchema()` wrapper) into the shape
* `@google/genai` accepts as `responseSchema`. Mirrors the inline pipeline
* the Vertex Gemini paths already use:
*
* convertZodToJsonSchema → inlineJsonSchema → strip `$schema` → ensure
* every nested schema has a `type` (Vertex/Gemini reject schemas missing
* that field, even on nested objects).
*
* Lives here so the AI Studio and Vertex paths can share the same
* sanitization without duplicating the schema-conversion churn.
*/
export function buildGeminiResponseSchema(schema) {
const raw = convertZodToJsonSchema(schema, "openApi3");
const inlined = inlineJsonSchema(raw);
if (inlined.$schema) {
delete inlined.$schema;
}
return ensureNestedSchemaTypes(inlined);
}
/**
* Map NeuroLink ChatMessage[] history into the @google/genai content format
* and push the entries onto a contents array.
*
* Used by the native Vertex Gemini and Google AI Studio paths to honor
* `options.conversationMessages` so multi-turn conversations (memory, loop
* REPL, agent flows) actually carry prior turns into the request.
*
* Behavior notes:
* - Only `user` and `assistant` roles are forwarded; system messages are
* expected to be wired via `systemInstruction`, and tool-call /
* tool-result roles only appear inside intra-call tool loops which build
* their own model/function entries.
* - String content is wrapped as a single `{ text }` part. Empty strings
* are skipped to avoid sending empty parts that some Gemini regions
* reject.
* - The current user input should be appended AFTER calling this helper
* so the prior turns appear first in chronological order.
*/
export function prependConversationMessages(contents, conversationMessages) {
if (!conversationMessages || conversationMessages.length === 0) {
return;
}
for (const msg of conversationMessages) {
if (msg.role !== "user" && msg.role !== "assistant") {
continue;
}
const text = typeof msg.content === "string" ? msg.content : "";
if (text.length === 0) {
continue;
}
contents.push({
role: msg.role === "assistant" ? "model" : "user",
parts: [{ text }],
});
}
}
/**
* Build the `parts` array for the current user turn of a Gemini native
* `generateContent` request, including inline image + PDF blobs.
*
* Both providers that hit the native `@google/genai` SDK — `GoogleVertex`
* and `GoogleAIStudio` — need this. The previous AI Studio code only
* pushed a single `{ text }` part, which silently dropped `input.images`
* and `input.pdfFiles` on the floor: the model received text only and
* legitimately reported "no image attached". Extracting this from the
* Vertex copy keeps both providers on one definition.
*
* Accepted shapes per element (mirroring the runtime behaviour the Vertex
* code already supported):
* - `Buffer` → used as-is
* - local file path → read via `readFileSync`, MIME guessed from extension
* - `data:<mime>;base64,...` URL → mime parsed, data base64-decoded
* - `http(s)://...` URL → fetched, mime from `content-type`
* - any other string → assumed to be a base64-encoded payload
*
* Image MIME guessing is conservative — only known extensions override the
* default `image/jpeg`. Fetch failures are logged and the offending entry
* is skipped rather than aborting the entire request, matching prior
* Vertex behaviour.
*/
export async function buildUserPartsWithMultimodal(input, textOverride, logPrefix = "[GeminiNative]") {
const text = typeof textOverride === "string" ? textOverride : (input?.text ?? "");
const parts = [{ text }];
if (input?.pdfFiles && input.pdfFiles.length > 0) {
logger.debug(`${logPrefix} Processing ${input.pdfFiles.length} PDF(s)`);
for (const pdfFile of input.pdfFiles) {
let pdfBuffer;
if (typeof pdfFile === "string") {
if (existsSync(pdfFile)) {
pdfBuffer = readFileSync(pdfFile);
}
else {
// Treat as already-base64-encoded payload
pdfBuffer = Buffer.from(pdfFile, "base64");
}
}
else {
pdfBuffer = pdfFile;
}
parts.push({
inlineData: {
mimeType: "application/pdf",
data: pdfBuffer.toString("base64"),
},
});
}
}
if (input?.images && input.images.length > 0) {
logger.debug(`${logPrefix} Processing ${input.images.length} image(s)`);
for (const rawImage of input.images) {
// `images` may carry plain Buffer/string values or `{ data, altText? }`
// objects. Normalise to the inner payload before format detection.
const image = rawImage && typeof rawImage === "object" && !Buffer.isBuffer(rawImage)
? rawImage.data
: rawImage;
let imageBuffer;
let mimeType = "image/jpeg";
if (typeof image === "string") {
if (existsSync(image)) {
imageBuffer = readFileSync(image);
const ext = extname(image).toLowerCase();
if (ext === ".png") {
mimeType = "image/png";
}
else if (ext === ".gif") {
mimeType = "image/gif";
}
else if (ext === ".webp") {
mimeType = "image/webp";
}
}
else if (image.startsWith("data:")) {
const matches = image.match(/^data:([^;]+);base64,(.+)$/);
if (matches) {
mimeType = matches[1];
imageBuffer = Buffer.from(matches[2], "base64");
}
else {
continue;
}
}
else if (image.startsWith("http://") ||
image.startsWith("https://")) {
try {
const response = await fetch(image);
if (!response.ok) {
logger.warn(`${logPrefix} Image fetch failed: ${response.status} ${response.statusText}, skipping`, { url: image });
continue;
}
const arrayBuffer = await response.arrayBuffer();
imageBuffer = Buffer.from(arrayBuffer);
const headerMime = response.headers.get("content-type");
if (headerMime && headerMime.startsWith("image/")) {
mimeType = headerMime.split(";")[0];
}
}
catch (fetchError) {
logger.warn(`${logPrefix} Image URL fetch threw, skipping: ${fetchError instanceof Error
? fetchError.message
: String(fetchError)}`, { url: image });
continue;
}
}
else {
imageBuffer = Buffer.from(image, "base64");
}
}
else {
imageBuffer = image;
}
if (!imageBuffer) {
continue;
}
parts.push({
inlineData: {
mimeType,
data: imageBuffer.toString("base64"),
},
});
}
}
return parts;
}