Skip to content

Instantly share code, notes, and snippets.

@crishoj
Created July 3, 2026 11:19
Show Gist options
  • Select an option

  • Save crishoj/4e6a0982f9c3c58d1c5c408bb1da54b1 to your computer and use it in GitHub Desktop.

Select an option

Save crishoj/4e6a0982f9c3c58d1c5c408bb1da54b1 to your computer and use it in GitHub Desktop.
SignatureRepair: LangChain.js middleware fixing Gemini 3.x thought_signature drops on streamed tool calls (langchainjs#9624)

SignatureRepair — LangChain.js middleware fixing Gemini 3.x thought_signature drops on streamed tool calls

Workaround for langchain-ai/langchainjs#9624.

The bug

Gemini 3.x (gemini-3-flash-preview, gemini-3.1-flash-lite, gemini-3.5-flash, …) requires that every replayed functionCall part carries back the thoughtSignature it was issued with. When you stream the turn that emits a tool call, @langchain/google-common's chunk merging concatenates the per-chunk signature arrays out of alignment with the re-serialized parts — you end up with e.g. signatures = [sig, ""] against a single functionCall part. The serializer then applies an exact-length guard (signatures array length must equal parts array length) and, on mismatch, silently drops ALL signatures. Vertex/GenAI rejects the next turn with 400 INVALID_ARGUMENT.

Symptom: the first turn (including the tool call) works; the second turn 400s. Non-streaming (invoke) is unaffected — only streaming triggers the misaligned merge.

The fix

Re-derive the parts-parallel signature array the serializer expects, before the next model call: one "" for the leading text part (if any), then one non-empty signature per tool call, in order. This is the same shape the non-streaming path produces, so the exact-length guard passes and the signatures survive. (Confirmed by multiple people on the upstream issue: dropping the exact-length guard and index-mapping the non-empty signatures onto the functionCall parts also fixes it — this middleware does that mapping from the outside, without patching the library.)

Usage

Add it to your agent's middleware chain (LangChain.js v1 agent middleware):

import { createAgent } from "langchain"
import { signatureRepair } from "./signatureRepair.ts"

const agent = createAgent({
  model,      // a Gemini 3.x model via @langchain/google-vertexai or @langchain/google
  tools,
  systemPrompt,
  middleware: [signatureRepair /*, ...others */],
})

Verified against @langchain/core@1.2.1, @langchain/google-common@2.2.0, @langchain/google-vertexai@2.2.0, langchain@1.2.x.

Caveats

  • Only touches messages where the non-empty signature count matches the tool-call count — shapes it doesn't recognize (e.g. thinking models emitting signed thought blocks) are left untouched.
  • Temporary. Remove once the exact-length guard is fixed upstream.
import { createMiddleware } from "langchain"
import type { AIMessage } from "@langchain/core/messages"
// gemini 3.x requires every replayed functionCall part to carry back its thoughtSignature.
// streamed chunk merging in @langchain/google-common@2.2.0 concatenates the per-chunk
// signature arrays out of alignment with the re-serialized parts (e.g. [sig, ""] against a
// single functionCall part), and the serializer's exact-length guard then silently drops
// ALL signatures — Vertex rejects the next turn with 400 INVALID_ARGUMENT.
// reported upstream: langchain-ai/langchainjs#9624 — remove this once fixed.
//
// repair: re-derive the parts-parallel array the serializer expects — one "" for the text
// part (if any), then one non-empty signature per tool call, in order. shapes we don't
// recognize (e.g. thinking models emitting signed thought blocks) are left untouched.
export const signatureRepair = createMiddleware({
name: "SignatureRepair",
wrapModelCall: async (request, handler) => {
for (const msg of request.messages) {
// merged stream chunks are AIMessageChunk, which implements but does not extend
// AIMessage — match on message type, not instanceof
if (msg.getType() !== "ai") continue
const m = msg as AIMessage
if (!m.tool_calls?.length) continue
const sigs = (m.additional_kwargs?.signatures ?? []) as string[]
const nonEmpty = sigs.filter((s) => s && s.length > 0)
if (nonEmpty.length === 0 || nonEmpty.length !== m.tool_calls.length)
continue
const hasText =
typeof m.content === "string"
? m.content.length > 0
: Array.isArray(m.content) &&
m.content.some(
(b) => typeof b === "object" && b.type === "text" && b.text,
)
const repaired = [...(hasText ? [""] : []), ...nonEmpty]
if (
repaired.length !== sigs.length ||
repaired.some((s, i) => s !== sigs[i])
) {
m.additional_kwargs.signatures = repaired
}
}
return handler(request)
},
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment