Skip to content

Commit c49f454

Browse files
pemonttoweb-flow
andcommitted
feat(ai): typed metadata on tool calls + thread through pipeline
Renames `providerMetadata` to `metadata` and types it per-adapter via a new `TToolCallMetadata` generic on `BaseTextAdapter`, mirroring the existing typed-metadata pattern on content parts (`ImagePart<TMetadata>`, etc.). Also threads metadata through the client-side UIMessage pipeline so it round-trips with each tool call, fixing the silent drop surfaced in #403/#404 (incorporates that PR's plumbing under the new name). Gemini adapter now declares `TToolCallMetadata = GeminiToolCallMetadata`, giving consumers typed `toolCall.metadata?.thoughtSignature` end-to-end. Per maintainer feedback on #459 (AlemTuzlak): use `metadata` (typed per-adapter generic) rather than the previous `providerMetadata` bag. The `ToolCallStartEvent` event remains non-generic with `metadata?: Record<string, unknown>` because making it generic breaks the AGUIEvent discriminated-union narrowing. Breaking: consumers reading `toolCall.providerMetadata` or `toolCallStartEvent.providerMetadata` should rename to `metadata`. Co-authored-by: houmark <noreply@github.com>
1 parent 6858973 commit c49f454

12 files changed

Lines changed: 101 additions & 27 deletions

File tree

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,26 @@
11
---
22
'@tanstack/ai-gemini': patch
3+
'@tanstack/ai': minor
4+
'@tanstack/ai-event-client': minor
35
---
46

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

7-
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` (which doesn't exist in the 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"`.
9+
Two fixes shipped together because the adapter fix is only effective once the framework also preserves provider metadata across the tool-call round-trip.
810

9-
This fix:
11+
**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"`.
1012

11-
- **Read side:** reads `part.thoughtSignature` directly, using the SDK's typed `Part` interface
12-
- **Write side:** emits `thoughtSignature` as a Part-level sibling of `functionCall`, using the SDK's typed `Part` interface
13+
- **Read side:** reads `part.thoughtSignature` directly using the SDK's typed `Part` interface
14+
- **Write side:** emits `thoughtSignature` as a Part-level sibling of `functionCall`
15+
16+
**Framework (typed tool-call metadata):**
17+
18+
- `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.).
19+
- `ToolCallPart` gains a typed `metadata?: TMetadata` field (also generic).
20+
- `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).
21+
- `BaseTextAdapter` and `TextAdapter` gain a sixth generic `TToolCallMetadata` (default `unknown`), exposed via `~types.toolCallMetadata` for inference at call sites.
22+
- `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).
23+
24+
**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.
25+
26+
**Breaking:** consumers reading `toolCall.providerMetadata` or `toolCallStartEvent.providerMetadata` should rename to `metadata`.

packages/typescript/ai-event-client/src/index.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,15 +86,17 @@ export type MessagePart =
8686
| ToolResultPart
8787
| ThinkingPart
8888

89-
export interface ToolCall {
89+
export interface ToolCall<TMetadata = unknown> {
9090
id: string
9191
type: 'function'
9292
function: {
9393
name: string
9494
arguments: string
9595
}
96-
/** Provider-specific metadata to carry through the tool call lifecycle */
97-
providerMetadata?: Record<string, unknown>
96+
/** Provider-specific metadata to carry through the tool call lifecycle.
97+
* Typed per-adapter via `TToolCallMetadata` (e.g. Gemini's
98+
* `{ thoughtSignature?: string }`). */
99+
metadata?: TMetadata
98100
}
99101

100102
/**

packages/typescript/ai-gemini/src/adapters/text.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ import type {
3333
TextOptions,
3434
} from '@tanstack/ai'
3535
import type { ExternalTextProviderOptions } from '../text/text-provider-options'
36-
import type { GeminiMessageMetadataByModality } from '../message-types'
36+
import type {
37+
GeminiMessageMetadataByModality,
38+
GeminiToolCallMetadata,
39+
} from '../message-types'
3740
import type { GeminiClientConfig } from '../utils'
3841

3942
/** Cast an event object to StreamChunk. Adapters construct events with string
@@ -104,7 +107,8 @@ export class GeminiTextAdapter<
104107
TProviderOptions,
105108
TInputModalities,
106109
GeminiMessageMetadataByModality,
107-
TToolCapabilities
110+
TToolCapabilities,
111+
GeminiToolCallMetadata
108112
> {
109113
readonly kind = 'text' as const
110114
readonly name = 'gemini' as const
@@ -435,9 +439,9 @@ export class GeminiTextAdapter<
435439
timestamp,
436440
index: toolCallData.index,
437441
...(toolCallData.thoughtSignature && {
438-
providerMetadata: {
442+
metadata: {
439443
thoughtSignature: toolCallData.thoughtSignature,
440-
},
444+
} satisfies GeminiToolCallMetadata,
441445
}),
442446
})
443447
}
@@ -714,8 +718,9 @@ export class GeminiTextAdapter<
714718
>
715719
}
716720

717-
const thoughtSignature = toolCall.providerMetadata
718-
?.thoughtSignature as string | undefined
721+
const thoughtSignature = (
722+
toolCall.metadata as GeminiToolCallMetadata | undefined
723+
)?.thoughtSignature
719724
// Gemini requires thoughtSignature at the Part level (sibling of
720725
// functionCall), not nested inside functionCall. Nesting it causes
721726
// the API to reject the next turn with

packages/typescript/ai-gemini/src/message-types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,17 @@ export interface GeminiMessageMetadataByModality {
130130
video: GeminiVideoMetadata
131131
document: GeminiDocumentMetadata
132132
}
133+
134+
/**
135+
* Provider-specific metadata that round-trips with each Gemini tool call.
136+
*
137+
* `thoughtSignature` is emitted by Gemini 3.x (and 2.5 thinking) models on
138+
* the Part containing the `functionCall`. The same signature must be echoed
139+
* back at the Part level on the next turn or the API rejects the request
140+
* with `400 INVALID_ARGUMENT: "Function call is missing a thought_signature"`.
141+
*
142+
* @see https://ai.google.dev/gemini-api/docs/thinking
143+
*/
144+
export interface GeminiToolCallMetadata {
145+
thoughtSignature?: string
146+
}

packages/typescript/ai/src/activities/chat/adapter.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,15 @@ export interface StructuredOutputResult<T = unknown> {
5454
* - TInputModalities: Supported input modalities for this model (already resolved)
5555
* - TMessageMetadata: Metadata types for content parts (already resolved)
5656
* - TToolCapabilities: Tuple of tool-kind strings supported by this model, resolved from `supports.tools`
57+
* - TToolCallMetadata: Metadata type that round-trips with tool calls (e.g. Gemini's `thoughtSignature`)
5758
*/
5859
export interface TextAdapter<
5960
TModel extends string,
6061
TProviderOptions extends Record<string, any>,
6162
TInputModalities extends ReadonlyArray<Modality>,
6263
TMessageMetadataByModality extends DefaultMessageMetadataByModality,
6364
TToolCapabilities extends ReadonlyArray<string> = ReadonlyArray<string>,
65+
TToolCallMetadata = unknown,
6466
> {
6567
/** Discriminator for adapter kind */
6668
readonly kind: 'text'
@@ -77,6 +79,7 @@ export interface TextAdapter<
7779
inputModalities: TInputModalities
7880
messageMetadataByModality: TMessageMetadataByModality
7981
toolCapabilities: TToolCapabilities
82+
toolCallMetadata: TToolCallMetadata
8083
}
8184

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

108111
/**
109112
* Abstract base class for text adapters.
@@ -117,12 +120,14 @@ export abstract class BaseTextAdapter<
117120
TInputModalities extends ReadonlyArray<Modality>,
118121
TMessageMetadataByModality extends DefaultMessageMetadataByModality,
119122
TToolCapabilities extends ReadonlyArray<string> = ReadonlyArray<string>,
123+
TToolCallMetadata = unknown,
120124
> implements TextAdapter<
121125
TModel,
122126
TProviderOptions,
123127
TInputModalities,
124128
TMessageMetadataByModality,
125-
TToolCapabilities
129+
TToolCapabilities,
130+
TToolCallMetadata
126131
> {
127132
readonly kind = 'text' as const
128133
abstract readonly name: string
@@ -134,6 +139,7 @@ export abstract class BaseTextAdapter<
134139
inputModalities: TInputModalities
135140
messageMetadataByModality: TMessageMetadataByModality
136141
toolCapabilities: TToolCapabilities
142+
toolCallMetadata: TToolCallMetadata
137143
}
138144

139145
protected config: TextAdapterConfig

packages/typescript/ai/src/activities/chat/messages.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,10 @@ interface AssistantSegment {
138138
id: string
139139
type: 'function'
140140
function: { name: string; arguments: string }
141+
/** Provider-specific metadata that round-trips with the tool call.
142+
* Untyped at this framework layer; adapters narrow it via their
143+
* `TToolCallMetadata` generic. */
144+
metadata?: unknown
141145
}>
142146
}
143147

@@ -205,6 +209,7 @@ function buildAssistantMessages(uiMessage: UIMessage): Array<ModelMessage> {
205209
name: part.name,
206210
arguments: part.arguments,
207211
},
212+
...(part.metadata !== undefined && { metadata: part.metadata }),
208213
})
209214
}
210215
break
@@ -340,6 +345,7 @@ export function modelMessageToUIMessage(
340345
name: toolCall.function.name,
341346
arguments: toolCall.function.arguments,
342347
state: 'input-complete', // Model messages have complete arguments
348+
...(toolCall.metadata !== undefined && { metadata: toolCall.metadata }),
343349
})
344350
}
345351
}

packages/typescript/ai/src/activities/chat/stream/message-updaters.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export function updateToolCallPart(
5555
name: string
5656
arguments: string
5757
state: ToolCallState
58+
metadata?: Record<string, unknown>
5859
},
5960
): Array<UIMessage> {
6061
return messages.map((msg) => {
@@ -67,6 +68,12 @@ export function updateToolCallPart(
6768
(p): p is ToolCallPart => p.type === 'tool-call' && p.id === toolCall.id,
6869
)
6970

71+
// Carry forward metadata from either the new toolCall or the existing
72+
// part. Once the adapter has emitted metadata for a tool call (e.g.
73+
// Gemini's thoughtSignature on TOOL_CALL_START) we must not lose it on
74+
// subsequent updates that don't re-supply it.
75+
const metadata = toolCall.metadata ?? existing?.metadata
76+
7077
const toolCallPart: ToolCallPart = {
7178
type: 'tool-call',
7279
id: toolCall.id,
@@ -76,6 +83,7 @@ export function updateToolCallPart(
7683
// Carry forward approval and output from the existing part
7784
...(existing?.approval && { approval: { ...existing.approval } }),
7885
...(existing?.output !== undefined && { output: existing.output }),
86+
...(metadata !== undefined && { metadata }),
7987
}
8088

8189
if (existing) {

packages/typescript/ai/src/activities/chat/stream/processor.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -899,13 +899,19 @@ export class StreamProcessor {
899899

900900
const toolName = chunk.toolCallName
901901

902+
// Capture provider metadata that arrived on TOOL_CALL_START so it
903+
// round-trips back through the assistant message on the next turn
904+
// (e.g. Gemini's thoughtSignature).
905+
const chunkMetadata = chunk.metadata
906+
902907
const newToolCall: InternalToolCallState = {
903908
id: chunk.toolCallId,
904909
name: toolName,
905910
arguments: '',
906911
state: initialState,
907912
parsedArguments: undefined,
908913
index: chunk.index ?? state.toolCalls.size,
914+
...(chunkMetadata !== undefined && { metadata: chunkMetadata }),
909915
}
910916

911917
state.toolCalls.set(toolCallId, newToolCall)
@@ -920,6 +926,7 @@ export class StreamProcessor {
920926
name: toolName,
921927
arguments: '',
922928
state: initialState,
929+
...(chunkMetadata !== undefined && { metadata: chunkMetadata }),
923930
})
924931
this.emitMessagesChange()
925932

@@ -1386,6 +1393,7 @@ export class StreamProcessor {
13861393
name: toolCall.name,
13871394
arguments: toolCall.arguments,
13881395
state: 'input-complete',
1396+
...(toolCall.metadata !== undefined && { metadata: toolCall.metadata }),
13891397
})
13901398
this.emitMessagesChange()
13911399

packages/typescript/ai/src/activities/chat/stream/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ export interface InternalToolCallState {
2525
state: ToolCallState
2626
parsedArguments?: any
2727
index: number
28+
/** Provider-specific metadata that round-trips with the tool call
29+
* (e.g. Gemini's `thoughtSignature`). Untyped at this layer because
30+
* the stream processor is provider-agnostic; adapters narrow it
31+
* via their `TToolCallMetadata` generic. */
32+
metadata?: Record<string, unknown>
2833
}
2934

3035
/**

packages/typescript/ai/src/activities/chat/tools/tool-calls.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,7 @@ export class ToolCallManager {
101101
name,
102102
arguments: '',
103103
},
104-
...(event.providerMetadata && {
105-
providerMetadata: event.providerMetadata,
106-
}),
104+
...(event.metadata !== undefined && { metadata: event.metadata }),
107105
})
108106
}
109107

0 commit comments

Comments
 (0)