Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/fix-gemini-thought-signature-part-level.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@tanstack/ai-gemini': patch
'@tanstack/ai': minor
'@tanstack/ai-event-client': minor
---

fix(ai-gemini): read/write thoughtSignature at Part level + thread typed metadata through tool-call lifecycle

Two fixes shipped together because the adapter fix is only effective once the framework also preserves provider metadata across the tool-call round-trip.

**Adapter (Gemini):** Gemini emits `thoughtSignature` as a Part-level sibling of `functionCall` (per the `@google/genai` `Part` type definition), not nested inside `functionCall`. The `FunctionCall` type has never had a `thoughtSignature` property. The adapter was reading from `functionCall.thoughtSignature` (does not exist in SDK types) and writing it back nested inside `functionCall`, causing Gemini 3.x to reject subsequent tool-call turns with `400 INVALID_ARGUMENT: "Function call is missing a thought_signature"`.

- **Read side:** reads `part.thoughtSignature` directly using the SDK's typed `Part` interface
- **Write side:** emits `thoughtSignature` as a Part-level sibling of `functionCall`

**Framework (typed tool-call metadata):**

- `ToolCall.providerMetadata: Record<string, unknown>` is now `ToolCall<TMetadata>.metadata?: TMetadata`, mirroring the existing typed-metadata pattern on content parts (`ImagePart<TMetadata>`, `AudioPart<TMetadata>`, etc.).
- `ToolCallPart` gains a typed `metadata?: TMetadata` field (also generic).
- `ToolCallStartEvent.providerMetadata` becomes `metadata` (kept as `Record<string, unknown>` because the AGUIEvent discriminated union does not survive a generic on the event type; adapters cast to their typed shape when emitting).
- `BaseTextAdapter` and `TextAdapter` gain a sixth generic `TToolCallMetadata` (default `unknown`), exposed via `~types.toolCallMetadata` for inference at call sites.
- `InternalToolCallState` gains a `metadata?: Record<string, unknown>` field captured at `TOOL_CALL_START` and threaded through `updateToolCallPart`, `buildAssistantMessages`, `modelMessageToUIMessage`, and `completeToolCall`, fixing a previously-silent drop of provider metadata across the client-side UIMessage pipeline (closes the gap surfaced in #403/#404).

**Gemini concrete impl:** new `GeminiToolCallMetadata { thoughtSignature?: string }` exported from `@tanstack/ai-gemini`. The adapter declares its `TToolCallMetadata` as this type, so consumers see `toolCall.metadata?.thoughtSignature` typed end-to-end.

**Breaking:** consumers reading `toolCall.providerMetadata` or `toolCallStartEvent.providerMetadata` should rename to `metadata`.
8 changes: 5 additions & 3 deletions packages/typescript/ai-event-client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,15 +86,17 @@ export type MessagePart =
| ToolResultPart
| ThinkingPart

export interface ToolCall {
export interface ToolCall<TMetadata = unknown> {
id: string
type: 'function'
function: {
name: string
arguments: string
}
/** Provider-specific metadata to carry through the tool call lifecycle */
providerMetadata?: Record<string, unknown>
/** Provider-specific metadata to carry through the tool call lifecycle.
* Typed per-adapter via `TToolCallMetadata` (e.g. Gemini's
* `{ thoughtSignature?: string }`). */
metadata?: TMetadata
}

/**
Expand Down
43 changes: 31 additions & 12 deletions packages/typescript/ai-gemini/src/adapters/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ import type {
TextOptions,
} from '@tanstack/ai'
import type { ExternalTextProviderOptions } from '../text/text-provider-options'
import type { GeminiMessageMetadataByModality } from '../message-types'
import type {
GeminiMessageMetadataByModality,
GeminiToolCallMetadata,
} from '../message-types'
import type { GeminiClientConfig } from '../utils'

/** Cast an event object to StreamChunk. Adapters construct events with string
Expand Down Expand Up @@ -104,7 +107,8 @@ export class GeminiTextAdapter<
TProviderOptions,
TInputModalities,
GeminiMessageMetadataByModality,
TToolCapabilities
TToolCapabilities,
GeminiToolCallMetadata
> {
readonly kind = 'text' as const
readonly name = 'gemini' as const
Expand Down Expand Up @@ -385,6 +389,11 @@ export class GeminiTextAdapter<
`${functionCall.name}_${Date.now()}_${nextToolIndex}`
const functionArgs = functionCall.args || {}

// Gemini emits thoughtSignature as a Part-level sibling of
// functionCall (per @google/genai Part type), not nested inside
// functionCall itself.
const partThoughtSignature = part.thoughtSignature || undefined

let toolCallData = toolCallMap.get(toolCallId)
if (!toolCallData) {
toolCallData = {
Expand All @@ -395,11 +404,13 @@ export class GeminiTextAdapter<
: JSON.stringify(functionArgs),
index: nextToolIndex++,
started: false,
thoughtSignature:
(functionCall as any).thoughtSignature || undefined,
thoughtSignature: partThoughtSignature,
}
toolCallMap.set(toolCallId, toolCallData)
} else {
if (!toolCallData.thoughtSignature && partThoughtSignature) {
toolCallData.thoughtSignature = partThoughtSignature
}
try {
const existingArgs = JSON.parse(toolCallData.args)
const newArgs =
Expand Down Expand Up @@ -428,9 +439,9 @@ export class GeminiTextAdapter<
timestamp,
index: toolCallData.index,
...(toolCallData.thoughtSignature && {
providerMetadata: {
metadata: {
thoughtSignature: toolCallData.thoughtSignature,
},
} satisfies GeminiToolCallMetadata,
}),
})
}
Expand Down Expand Up @@ -707,16 +718,24 @@ export class GeminiTextAdapter<
>
}

const thoughtSignature = toolCall.providerMetadata
?.thoughtSignature as string | undefined
parts.push({
const thoughtSignature = (
toolCall.metadata as GeminiToolCallMetadata | undefined
)?.thoughtSignature
// Gemini requires thoughtSignature at the Part level (sibling of
// functionCall), not nested inside functionCall. Nesting it causes
// the API to reject the next turn with
// "Function call is missing a thought_signature".
const part: Part = {
functionCall: {
id: toolCall.id,
name: toolCall.function.name,
args: parsedArgs,
...(thoughtSignature && { thoughtSignature }),
} as any,
})
},
}
if (thoughtSignature) {
part.thoughtSignature = thoughtSignature
}
parts.push(part)
}
}

Expand Down
14 changes: 14 additions & 0 deletions packages/typescript/ai-gemini/src/message-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,17 @@ export interface GeminiMessageMetadataByModality {
video: GeminiVideoMetadata
document: GeminiDocumentMetadata
}

/**
* Provider-specific metadata that round-trips with each Gemini tool call.
*
* `thoughtSignature` is emitted by Gemini 3.x (and 2.5 thinking) models on
* the Part containing the `functionCall`. The same signature must be echoed
* back at the Part level on the next turn or the API rejects the request
* with `400 INVALID_ARGUMENT: "Function call is missing a thought_signature"`.
*
* @see https://ai.google.dev/gemini-api/docs/thinking
*/
export interface GeminiToolCallMetadata {
thoughtSignature?: string
}
96 changes: 90 additions & 6 deletions packages/typescript/ai-gemini/tests/gemini-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,22 +502,23 @@ describe('GeminiAdapter through AI', () => {
expect(textParts[0].text).toBe("what's a good electric guitar?")
})

it('preserves thoughtSignature in functionCall parts when sending history back to Gemini', async () => {
it('reads Part-level thoughtSignature from Gemini 3.x streaming response', async () => {
const thoughtSig = 'base64-encoded-thought-signature-xyz'

// First stream: model returns a function call with a thoughtSignature (thinking model)
// Gemini 3.x emits thoughtSignature at the Part level, as a sibling of
// functionCall (per @google/genai Part type), not nested inside functionCall.
const firstStream = [
{
candidates: [
{
content: {
parts: [
{
thoughtSignature: thoughtSig,
functionCall: {
id: 'fc_001',
name: 'sum_tool',
args: { numbers: [1, 2, 5] },
thoughtSignature: thoughtSig,
},
},
],
Expand All @@ -533,7 +534,6 @@ describe('GeminiAdapter through AI', () => {
},
]

// Second stream: model returns the final answer
const secondStream = [
{
candidates: [
Expand Down Expand Up @@ -587,8 +587,92 @@ describe('GeminiAdapter through AI', () => {
const functionCallPart = modelTurn.parts.find((p: any) => p.functionCall)
expect(functionCallPart).toBeDefined()
expect(functionCallPart.functionCall.name).toBe('sum_tool')
// The thoughtSignature must be preserved in the model turn's functionCall
expect(functionCallPart.functionCall.thoughtSignature).toBe(thoughtSig)
// thoughtSignature must be at the Part level, NOT nested in functionCall
expect(functionCallPart.thoughtSignature).toBe(thoughtSig)
expect(functionCallPart.functionCall.thoughtSignature).toBeUndefined()
})

it('ignores thoughtSignature nested inside functionCall (not part of @google/genai Part type)', async () => {
// The @google/genai SDK has never typed thoughtSignature on FunctionCall;
// it only exists on Part. A nested value should be ignored.
const firstStream = [
{
candidates: [
{
content: {
parts: [
{
functionCall: {
id: 'fc_nested',
name: 'sum_tool',
args: { numbers: [3, 4] },
thoughtSignature: 'should-be-ignored',
},
},
],
},
finishReason: 'STOP',
},
],
usageMetadata: {
promptTokenCount: 10,
candidatesTokenCount: 5,
totalTokenCount: 15,
},
},
]

const secondStream = [
{
candidates: [
{
content: { parts: [{ text: 'The sum is 7.' }] },
finishReason: 'STOP',
},
],
usageMetadata: {
promptTokenCount: 20,
candidatesTokenCount: 5,
totalTokenCount: 25,
},
},
]

mocks.generateContentStreamSpy
.mockResolvedValueOnce(createStream(firstStream))
.mockResolvedValueOnce(createStream(secondStream))

const adapter = createTextAdapter()

const sumTool: Tool = {
name: 'sum_tool',
description: 'Sums an array of numbers.',
execute: async (input: any) => ({
result: input.numbers.reduce((a: number, b: number) => a + b, 0),
}),
}

for await (const _ of chat({
adapter,
tools: [sumTool],
messages: [{ role: 'user', content: 'What is 3 + 4?' }],
})) {
/* consume stream */
}

expect(mocks.generateContentStreamSpy).toHaveBeenCalledTimes(2)

const [secondPayload] = mocks.generateContentStreamSpy.mock.calls[1]
const modelTurn = secondPayload.contents.find(
(c: any) => c.role === 'model',
)
expect(modelTurn).toBeDefined()

const functionCallPart = modelTurn.parts.find((p: any) => p.functionCall)
expect(functionCallPart).toBeDefined()
// No thoughtSignature should be emitted since none was at Part level
expect(functionCallPart.thoughtSignature).toBeUndefined()
expect(functionCallPart.functionCall.thoughtSignature).toBeUndefined()
})

it('uses function name (not toolCallId) in functionResponse and preserves the call id', async () => {
Expand Down
10 changes: 8 additions & 2 deletions packages/typescript/ai/src/activities/chat/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,15 @@ export interface StructuredOutputResult<T = unknown> {
* - TInputModalities: Supported input modalities for this model (already resolved)
* - TMessageMetadata: Metadata types for content parts (already resolved)
* - TToolCapabilities: Tuple of tool-kind strings supported by this model, resolved from `supports.tools`
* - TToolCallMetadata: Metadata type that round-trips with tool calls (e.g. Gemini's `thoughtSignature`)
*/
export interface TextAdapter<
TModel extends string,
TProviderOptions extends Record<string, any>,
TInputModalities extends ReadonlyArray<Modality>,
TMessageMetadataByModality extends DefaultMessageMetadataByModality,
TToolCapabilities extends ReadonlyArray<string> = ReadonlyArray<string>,
TToolCallMetadata = unknown,
> {
/** Discriminator for adapter kind */
readonly kind: 'text'
Expand All @@ -77,6 +79,7 @@ export interface TextAdapter<
inputModalities: TInputModalities
messageMetadataByModality: TMessageMetadataByModality
toolCapabilities: TToolCapabilities
toolCallMetadata: TToolCallMetadata
}

/**
Expand All @@ -103,7 +106,7 @@ export interface TextAdapter<
* A TextAdapter with any/unknown type parameters.
* Useful as a constraint in generic functions and interfaces.
*/
export type AnyTextAdapter = TextAdapter<any, any, any, any, any>
export type AnyTextAdapter = TextAdapter<any, any, any, any, any, any>

/**
* Abstract base class for text adapters.
Expand All @@ -117,12 +120,14 @@ export abstract class BaseTextAdapter<
TInputModalities extends ReadonlyArray<Modality>,
TMessageMetadataByModality extends DefaultMessageMetadataByModality,
TToolCapabilities extends ReadonlyArray<string> = ReadonlyArray<string>,
TToolCallMetadata = unknown,
> implements TextAdapter<
TModel,
TProviderOptions,
TInputModalities,
TMessageMetadataByModality,
TToolCapabilities
TToolCapabilities,
TToolCallMetadata
> {
readonly kind = 'text' as const
abstract readonly name: string
Expand All @@ -134,6 +139,7 @@ export abstract class BaseTextAdapter<
inputModalities: TInputModalities
messageMetadataByModality: TMessageMetadataByModality
toolCapabilities: TToolCapabilities
toolCallMetadata: TToolCallMetadata
}

protected config: TextAdapterConfig
Expand Down
6 changes: 6 additions & 0 deletions packages/typescript/ai/src/activities/chat/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,10 @@ interface AssistantSegment {
id: string
type: 'function'
function: { name: string; arguments: string }
/** Provider-specific metadata that round-trips with the tool call.
* Untyped at this framework layer; adapters narrow it via their
* `TToolCallMetadata` generic. */
metadata?: unknown
}>
}

Expand Down Expand Up @@ -205,6 +209,7 @@ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
name: part.name,
arguments: part.arguments,
},
...(part.metadata !== undefined && { metadata: part.metadata }),
})
}
break
Expand Down Expand Up @@ -340,6 +345,7 @@ export function modelMessageToUIMessage(
name: toolCall.function.name,
arguments: toolCall.function.arguments,
state: 'input-complete', // Model messages have complete arguments
...(toolCall.metadata !== undefined && { metadata: toolCall.metadata }),
})
}
}
Expand Down
Loading