UNPKG

@juspay/neurolink

Version:

The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002

544 lines (543 loc) • 27.5 kB
/** * 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 type { GenerateStopReason, ThinkingConfig, AgenticLoopOptions, ChatMessage, CollectedChunkResult, MinimalChatMessage, NativeFunctionCall, NativeFunctionResponse, NativeToolDeclarationsResult, NativeToolsConfig, StreamChannel, VertexNativePart, GeminiMultimodalInput, MultimodalAudioEntry, MultimodalVideoEntry, ModelMessage } from "../../types/index.js"; import type { Tool, ToolExecutionGuards } from "../../types/index.js"; /** * A per-turn tool execute map that deduplicates identical tool calls. * * Gemini occasionally re-emits a tool call with identical arguments across * agentic steps even though the prior result is already in the conversation * history (BZ-3327). Re-executing produces duplicate side effects and * duplicate reports for the user, plus wasted tokens. This map caches the * result of each {tool name + args} executed within the turn and returns it * for any identical re-request instead of running the tool again. * * Providers build a fresh executeMap per request, so the cache scope is * exactly one turn. Only `.get()` is overridden (the sole access path used by * the native agentic loops), so iteration still yields the raw executors. */ export declare class DedupExecuteMap extends Map<string, Tool["execute"]> { private readonly resultCache; get(name: string): Tool["execute"] | undefined; } /** * 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 declare function buildNativeToolDeclarations(tools: Record<string, Tool>, reservedNames?: ReadonlySet<string>): NativeToolDeclarationsResult; /** * Build the tool record handed to `runAgenticLoop`, routed through the turn's * DedupExecuteMap. * * The engine looks tools up by the name the adapter reports, which is the * ORIGINAL caller-facing name; `executeMap` is keyed by the SANITIZED wire * name Google actually declares. `originalNameMap` is the bridge, and it * carries an entry for every converted tool (identity mappings included), so * iterating it yields exactly the declared, executable set. * * Going through `executeMap.get()` rather than the raw `tool.execute` is the * entire point: `.get()` returns the dedup wrapper, so an identical * {name, args} repeated within one turn is answered from the per-turn cache * instead of running the tool again (BZ-3327). Passing the raw executor looks * identical in every test that calls a tool once, and silently reintroduces * duplicate side effects the moment the model repeats itself. */ export declare function buildDedupedEngineTools(declarations: NativeToolDeclarationsResult | undefined, tools: Record<string, Tool> | undefined, guards?: ToolExecutionGuards): NonNullable<AgenticLoopOptions["tools"]>; /** * Mid-turn tool sync for the native Gemini loops that build their snapshot * via buildNativeToolDeclarations. `search_tools` (tools.discovery) hydrates * discovered tools into the live record between steps; without this refresh * they stay invisible to the rest of the turn and every call dies as * TOOL_NOT_FOUND. Mutates the snapshot in place — the request config holds * `toolsConfig` by reference — and returns the original names of the tools it * added (empty when nothing was added). */ export declare function refreshNativeToolDeclarations(liveTools: Record<string, Tool> | undefined, current: NativeToolDeclarationsResult): string[]; /** * 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 declare function buildNativeConfig(options: { temperature?: number; /** * Nucleus sampling. Subject to the same registry-driven * sampling-param strip as `temperature` — a model that rejects * classic sampling params (see `SAMPLING_PARAM_REJECTING_FAMILIES`) * gets neither on the wire. */ topP?: number; maxTokens?: number; systemPrompt?: string; /** * Model id, used for the registry-driven sampling-param strip and to * pick the right thinkingConfig wire shape (thinkingLevel for Gemini 3, * a translated numeric thinkingBudget for Gemini 2.5). */ model?: string; thinkingConfig?: ThinkingConfig; /** * When true (and `toolsConfig` is undefined), set * `responseMimeType: "application/json"` to enforce native JSON output. */ wantsJsonOutput?: boolean; /** * Pre-converted JSON Schema for native `responseSchema`. Implies * `wantsJsonOutput`. Ignored if `toolsConfig` is present. */ responseSchema?: Record<string, unknown>; }, toolsConfig?: NativeToolsConfig): Record<string, unknown>; /** * Compute a safe, clamped maxSteps value. */ export declare function computeMaxSteps(rawMaxSteps?: number): number; /** * Map a `@google/genai` `Candidate.finishReason` enum value onto NeuroLink's * unified finish reason, mirroring anthropic.ts `mapAnthropicStopReason`. * * Enum values per `@google/genai` `FinishReason`: STOP, MAX_TOKENS, SAFETY, * RECITATION, LANGUAGE, OTHER, BLOCKLIST, PROHIBITED_CONTENT, SPII, * MALFORMED_FUNCTION_CALL, IMAGE_SAFETY, UNEXPECTED_TOOL_CALL, * FINISH_REASON_UNSPECIFIED. Unknown / unset / non-terminal values default to * "stop" (a clean completion is the safe assumption). * * Returns a plain string (not a `{ unified, raw }` object): the Vertex result * builders and the consuming layer (neurolink.ts `finishReason || "unknown"` * and `finishReason === "length"`) compare against plain strings. * * MALFORMED_FUNCTION_CALL / UNEXPECTED_TOOL_CALL map to "error", NOT * "tool-calls": they are provider/model failures, while "tool-calls" is the * exclusive contract for "step budget exhausted while the model still wanted * tools" — consumers (e.g. curator's step-cap intercept) branch on it and * were rendering fake step-limit messages for 2-4-step malformed-call turns. */ export declare function mapGeminiFinishReason(raw: string | null | undefined): "stop" | "length" | "tool-calls" | "content-filter" | "error"; /** * Append a step's text to a running cross-step accumulator, ignoring empty * steps and inserting a single newline between non-empty contributions. * * The native Gemini loops overwrite per-step text into `lastStepText`, so when * the loop is force-terminated by the step cap the intermediate tool-step prose * is lost and a canned placeholder becomes the answer. Accumulating here mirrors * the Vertex-Claude loop's `aggregatedTurnText += block.text` so the gathered * text can be surfaced at the maxSteps-exhaustion exit instead of the placeholder. */ export declare function appendStepText(accumulated: string, stepText: string): string; /** * Process stream chunks to extract raw response parts, function calls, and usage metadata. * * Consumes the full async iterable and returns all collected data. */ export declare function collectStreamChunks(stream: AsyncIterable<{ functionCalls?: NativeFunctionCall[]; [key: string]: unknown; }>): Promise<CollectedChunkResult>; /** * 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 declare function collectStreamChunksIncremental(stream: AsyncIterable<{ functionCalls?: NativeFunctionCall[]; [key: string]: unknown; }>, channel: StreamChannel<{ content: string; }>): Promise<CollectedChunkResult>; /** * Extract the thoughtSignature token from raw response parts. * Returns the last thoughtSignature found (each step may produce one). */ export declare function extractThoughtSignature(rawResponseParts: unknown[]): string | undefined; /** * Extract text from raw response parts, filtering out non-text parts * (thoughtSignature, functionCall) to avoid SDK warnings. */ export declare function extractTextFromParts(rawResponseParts: unknown[]): string; /** * 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 declare function executeNativeToolCalls(logLabel: string, stepFunctionCalls: NativeFunctionCall[], executeMap: Map<string, Tool["execute"]>, failedTools: Map<string, { count: number; lastError: string; }>, allToolCalls: Array<{ toolName: string; args: Record<string, unknown>; }>, options?: { toolExecutions?: Array<{ name: string; input: Record<string, unknown>; output: unknown; }>; abortSignal?: AbortSignal; originalNameMap?: Map<string, string>; /** * Live tool record + declaration snapshot for mid-turn discovery: on a * dispatch miss the executor re-resolves against `liveTools` (hydrated * by search_tools, or auto-hydrated from the deferred catalog) and syncs * `declarations` in place. `declarations.executeMap` must be the same * map passed as the `executeMap` argument (true at every call site). */ liveTools?: Record<string, Tool>; declarations?: NativeToolDeclarationsResult; }): Promise<NativeFunctionResponse[]>; /** * Handle maxSteps termination by producing a final answer when the model was * still calling tools as the step limit was reached. * * Resolution order (always returns a non-empty string): the caller's * `finalText` if present; else the `lastStepText` gathered from the final step; * else a graceful, user-facing cap message from {@link buildToolLoopCapMessage} * — which replaced the legacy bracketed step-limit placeholder string. * * @param logLabel - Label for log messages (e.g. "[GoogleAIStudio]" or "[GoogleVertex]") * @returns The resolved final answer text (never the legacy placeholder). */ export declare function handleMaxStepsTermination(logLabel: string, step: number, maxSteps: number, finalText: string, lastStepText: string): string; /** * Detect whether an error represents an abort/cancellation (from an * AbortSignal firing during a fetch/stream drain). Used by the native * Gemini-3 agentic loops to break gracefully rather than re-throw. */ export declare function isAbortError(error: unknown): boolean; /** * Build a graceful, user-facing message for when a single agentic turn hits * the step cap without producing a final answer. Replaces the legacy * bracketed "Tool execution limit reached" placeholder. * * Emit this ONLY when `step >= maxSteps` genuinely terminated the loop — * aborted/timed-out/stalled turns must use {@link buildAbortedTurnMessage}, * {@link buildTurnTimeoutMessage}, or {@link buildTurnStalledMessage}, never * this text (a killed 23-step turn once claimed "reached the 200-step limit"). */ export declare function buildToolLoopCapMessage(maxSteps: number, toolCallCount: number): string; /** * Honest message for a turn stopped by the in-loop context guard * (stopReason "context-cap") without a synthesized answer. Sibling of * {@link buildToolLoopCapMessage} — context exits must never claim a step * limit was reached. */ export declare function buildContextCapMessage(toolCallCount: number): string; /** * Honest message for a turn ended by the `turnTimeoutMs` wall-clock deadline * (stopReason "time-limit"). Sibling of {@link buildToolLoopCapMessage} — * time exits must never claim a step limit was reached. */ export declare function buildTurnTimeoutMessage(elapsedMs: number, toolCallCount: number): string; /** * Honest message for a turn ended by the `stallTimeoutMs` no-progress * watchdog (stopReason "stalled") — typically a wedged tool or a hung * model call. */ export declare function buildTurnStalledMessage(stallTimeoutMs: number, toolCallCount: number): string; /** * Honest message for a caller-aborted turn (admin kill, coding-task * short-circuit — stopReason "aborted"). */ export declare function buildAbortedTurnMessage(toolCallCount: number): string; /** * Pick the honest terminal message for a native-loop exit by its ACTUAL * cause. The step-cap text is the fallback for genuine budget exhaustion * only — time/stall/abort exits must never claim a step limit was reached * (the 2026-07-03 "reached the 200-step limit" incident was a wall-clock * abort wearing the cap message). * * Takes plain booleans rather than a turn clock so a loop whose wall-clock * deadline is owned by something other than `createTurnClock` — AI Studio's * pre-existing per-request timeout controller — can report through the same * builders instead of growing a second set. */ export declare function buildLoopExitMessage(params: { timedOut: boolean; stalled: boolean; /** Turn duration so far, for the time-limit message. */ elapsedMs: number; wasAborted: boolean; stallTimeoutMs?: number; maxSteps: number; toolCallCount: number; }): string; /** * Wrap-up nudge injected when the remaining turn time drops inside the * `wrapupTimeLeadMs` window — the time-budget twin of the soft step-budget * nudge. Rides as a trailing text block on the tool-result user turn. */ export declare function buildWrapupNudgeText(useFinalResultTool: boolean): string; /** * Resolve the turn's `stopReason` discriminator from the loop's exit * bookkeeping. Precedence: time conditions beat the generic abort flag * (the deadline/stall watchdogs abort through the same internal controller), * abort beats step-cap, and a provider-error finishReason (e.g. persistent * MALFORMED_FUNCTION_CALL) beats "completed". */ export declare function resolveTurnStopReason(params: { timedOut: boolean; stalled: boolean; wasAborted: boolean; /** Step budget ran out AND no clean/forced answer was produced. */ cappedWithoutAnswer: boolean; /** Context guard stopped the loop AND no clean/forced answer was produced. */ contextCappedWithoutAnswer?: boolean; /** The turn's resolved unified finishReason. */ finishReason?: string; }): GenerateStopReason; /** * Wall-clock + progress watchdogs for a native agentic turn. * * - Deadline: `turnTimeoutMs` (caller knob) or `defaultTurnTimeoutMs` (the * loop's pre-existing defensive bound) arms a whole-turn timer. * - Stall: when `stallTimeoutMs` is set, a low-frequency interval checks the * time since the last recorded progress (chunk received, tool started or * finished, step started). * * Both fire `onDeadline(kind)` exactly once each and latch a flag the loop's * terminal handling reads to pick the honest exit message and `stopReason`. * Timers are unref'd so they never hold the process open; call `dispose()` * in the loop's finally. */ export declare function createTurnClock(params: { /** Explicit whole-turn deadline (ms); wins over defaultTurnTimeoutMs. */ turnTimeoutMs?: number; /** Loop-specific defensive default when turnTimeoutMs is unset (undefined = no deadline). */ defaultTurnTimeoutMs?: number; /** No-progress watchdog (ms); undefined = disabled. */ stallTimeoutMs?: number; /** Wrap-up lead (ms); only honored when turnTimeoutMs is explicitly set. */ wrapupTimeLeadMs?: number; onDeadline: (kind: "timeout" | "stall") => void; }): { readonly timedOut: boolean; readonly stalled: boolean; /** True when the turn ended on a time condition (deadline or stall). */ readonly expired: boolean; /** The effective whole-turn deadline in ms (explicit or defensive default). */ readonly turnTimeoutMs: number | undefined; elapsedMs(): number; /** Record progress: chunk received, tool started/finished, step started. */ noteProgress(): void; /** True when an explicit deadline exists and remaining time is inside the wrap-up lead. */ shouldNudgeWrapup(): boolean; dispose(): void; }; /** * In-loop context guard for native agentic turns. * * The provider reports the ACTUAL prompt size of every model call * (usage.input_tokens + cache reads/writes on Anthropic; * usageMetadata.promptTokenCount on Gemini). The guard tracks that number * plus an estimate of what the current step appends (assistant output, tool * results), and tells the loop to stop calling tools once the projected next * prompt crosses `thresholdRatio` of the model's context window — the turn * then synthesizes a final answer from what it has instead of stepping into * a provider 400 ("prompt is too long") that destroys all completed work. * * Fail-open: until the first usage report arrives, `shouldStop()` is false. */ export declare function createContextGuard(contextWindowTokens: number, thresholdRatio?: number): { /** Tokens at which the guard trips (ratio × window). */ readonly thresholdTokens: number; /** Last observed prompt size plus estimated growth since. */ readonly projectedNextPromptTokens: number; /** * Record a model call's reported usage. `promptTokens` must be the FULL * prompt size (uncached input + cache read + cache creation for * Anthropic). The response's own output is counted as growth — it is * appended to the conversation for the next call. */ noteUsage(promptTokens: number, outputTokens: number): void; /** * Add growth for content appended since the last model call (tool * results, nudge text) using the ~4 chars/token heuristic. */ noteAppendedChars(chars: number): void; /** * Clear the projection after the caller has reclaimed context. * * The observed prompt size reflects the pre-reclaim conversation, so * leaving it in place would keep `shouldStop()` true forever and defeat * the reclaim. Resetting to the fail-open state means the guard stays * quiet until the next real usage report re-establishes the truth. */ resetAfterReclaim(): void; /** True when issuing another model call risks crossing the threshold. */ shouldStop(): boolean; }; /** * Push model response parts to conversation history, preserving thoughtSignature * for Gemini 3 multi-turn tool calling. */ export declare function pushModelResponseToHistory(currentContents: Array<{ role: string; parts: unknown[]; }>, rawResponseParts: unknown[], stepFunctionCalls: NativeFunctionCall[]): void; /** * 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 declare function buildGeminiResponseSchema(schema: unknown): Record<string, unknown>; /** * 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 declare function prependConversationMessages(contents: Array<{ role: string; parts: unknown[]; }>, conversationMessages?: Array<ChatMessage | MinimalChatMessage>): void; /** * Convert a native Gemini `contents` array (the shape this module builds and * `prependConversationMessages` emits) into the generic V3 prompt shape that * caller model middleware operates on. * * Gemini has no wire-visible `system` role — `systemInstruction` rides * separately on `config` — so this only ever emits `user` / `assistant` / * `tool` messages. A `functionResponse`-only `user` entry becomes a V3 * `tool` message rather than `user`: `UserModelMessage.content` cannot carry * a `ToolResultPart`, only `ToolModelMessage.content` can, and that grouping * (a `model` turn of `functionCall` parts followed by a `user` turn of * matching `functionResponse` parts) is exactly what * `prependConversationMessages` above produces. */ export declare function geminiContentsToV3Prompt(contents: Array<{ role: string; parts: unknown[]; }>): ModelMessage[]; /** * Reverse of `geminiContentsToV3Prompt`: rebuild a native Gemini `contents` * array from a (possibly middleware-rewritten) V3 prompt. A `system` * message has no `contents` entry of its own — Gemini carries it on * `config.systemInstruction` — so its text is returned separately * (`systemText`) and the caller folds it into the request config. * * The synthesized `toolCallId` from the forward direction is intentionally * discarded: Gemini pairs a call to its result by position, not by id, so * reconstruction only needs `{name, args}` / `{name, response}`. */ export declare function v3PromptToGeminiContents(prompt: ModelMessage[]): { contents: Array<{ role: string; parts: unknown[]; }>; systemText?: string; }; /** * 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. */ /** * Append audio to a Gemini request as `inlineData` parts. * * Shared by both Gemini front ends. Vertex assembles its request here and AI * Studio assembles it in `buildUserPartsWithMultimodal`; when this lived only in * the Vertex client, AI Studio advertised audio support through * `NATIVE_AUDIO_PROVIDERS` and then silently dropped the bytes. * * Gemini's native request shape is assembled directly rather than taken from the * AI SDK's `file` parts, so audio has to be added explicitly the same way PDFs * and images are — a `{ type: "file" }` part built upstream simply never * reaches this request body. That asymmetry is why attaching a recording * produced only the metadata summary even after the message builder learned to * carry the bytes. * * A container Gemini does not accept is converted first; one that cannot be * converted is skipped rather than sent, because an unsupported inlineData * mimeType fails the whole request, and the caller still has the metadata * summary in the text part. */ export declare function appendNativeAudioParts(userParts: VertexNativePart[], audioFiles: MultimodalAudioEntry[] | undefined, logPrefix?: string): Promise<void>; /** * Append video to a Gemini request as `inlineData` parts. * * The native request body is assembled by hand rather than taken from the AI * SDK's `file` parts, so — exactly as with audio — a `{ type: "file" }` part * built upstream never arrives here. Video has to be added explicitly or it * is silently dropped on both Gemini front ends while the capability table * says it is supported. * * A clip the provider will not take inline is skipped rather than sent: an * oversized or unsupported `inlineData` part fails the whole request, whereas * skipping leaves the caller with the metadata summary and the keyframes, * which is what it had before native delivery existed. * * `model` is forwarded to `canDeliverVideoNatively` so a Claude-on-Vertex * request — same `providerName` ("vertex") as a Gemini-on-Vertex one, routed * to the native Anthropic SDK instead by `GoogleVertexProvider.isAnthropicModel` * — is answered as not natively video-capable rather than inheriting Vertex's * Gemini row. Optional because AI Studio's caller never has a Claude model to * pass. */ export declare function appendNativeVideoParts(userParts: VertexNativePart[], videoFiles: MultimodalVideoEntry[] | undefined, providerName: string, model?: string, logPrefix?: string): Promise<void>; export declare function buildUserPartsWithMultimodal(input: GeminiMultimodalInput | undefined, textOverride?: string, logPrefix?: string, model?: string): Promise<VertexNativePart[]>;