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
5 changes: 5 additions & 0 deletions .changeset/gemini-parallel-tool-calls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ai-sdk/google": patch
---

fix(google): avoid Gemini 3 missing-signature warnings for signed parallel tool call batches
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { google } from '@ai-sdk/google';
import { stepCountIs, streamText, tool } from 'ai';
import { z } from 'zod';

async function main() {
const result = streamText({
model: google('gemini-3.1-flash-lite-preview'),
abortSignal: AbortSignal.timeout(120_000),
stopWhen: stepCountIs(5),
tools: {
get_weather: tool({
description: 'Get the current weather for a given city.',
inputSchema: z.object({ city: z.string() }),
execute: async ({ city }) => ({
city,
tempC: city === 'Paris' ? 21 : city === 'Tokyo' ? 24 : 19,
conditions: 'sunny',
}),
}),
},
prompt:
'Use the get_weather tool to check the weather in Paris, Tokyo, and New York. Make exactly three get_weather tool calls in parallel in the same assistant step before you summarize.',
});

for await (const _ of result.textStream) {
// drain
}

const finalText = await result.text;
const warnings = await result.warnings;
const steps = await result.steps;

console.log('WARNINGS:');
console.log(JSON.stringify(warnings, null, 2));

console.log('\nFINAL TEXT:');
console.log(finalText);

console.log('\nTOOL-CALL PARTS:');
let toolCallCount = 0;
let signedToolCallCount = 0;
let unsignedToolCallCount = 0;
let toolResultCount = 0;
let multiStepRoundtrip = false;

for (const [stepIndex, step] of steps.entries()) {
const stepToolCallCount = step.content.filter(
part => part.type === 'tool-call',
).length;
const stepToolResultCount = step.content.filter(
part => part.type === 'tool-result',
).length;

if (stepToolCallCount >= 3 && stepToolResultCount >= 3) {
multiStepRoundtrip =
multiStepRoundtrip ||
steps
.slice(stepIndex + 1)
.some(laterStep =>
laterStep.content.some(
part => part.type === 'text' && part.text.trim().length > 0,
),
);
}

for (const part of step.content) {
if (part.type === 'tool-result') {
toolResultCount++;
continue;
}

if (part.type !== 'tool-call') {
continue;
}

toolCallCount++;
const thoughtSignature =
(
part.providerMetadata as
| { vertex?: { thoughtSignature?: unknown } }
| null
| undefined
)?.vertex?.thoughtSignature ??
(
part.providerMetadata as
| { google?: { thoughtSignature?: unknown } }
| null
| undefined
)?.google?.thoughtSignature ??
(
part.providerMetadata as
| { googleVertex?: { thoughtSignature?: unknown } }
| null
| undefined
)?.googleVertex?.thoughtSignature;

if (thoughtSignature == null) {
unsignedToolCallCount++;
} else {
signedToolCallCount++;
}

console.log(
[
`step[${stepIndex}]`,
`tool-call=${part.toolName}`,
`thoughtSignature=${thoughtSignature == null ? 'ABSENT' : 'PRESENT'}`,
`providerMetadata=${JSON.stringify(part.providerMetadata ?? null)}`,
].join(' '),
);
}
}

const warningText = JSON.stringify(warnings);
const fixed =
toolCallCount >= 3 &&
toolResultCount >= 3 &&
signedToolCallCount >= 1 &&
unsignedToolCallCount >= 2 &&
multiStepRoundtrip &&
!warningText.includes('skip_thought_signature_validator');

console.log('\nSUMMARY:');
console.log(
JSON.stringify(
{
stepCount: steps.length,
toolCallCount,
toolResultCount,
signedToolCallCount,
unsignedToolCallCount,
finalTextPresent: finalText.trim().length > 0,
multiStepRoundtrip,
hasSkipThoughtSignatureValidatorWarning: warningText.includes(
'skip_thought_signature_validator',
),
fixed,
},
null,
2,
),
);

if (!fixed) {
throw new Error(
'Expected a Gemini 3 multi-step roundtrip with at least three parallel tool calls, matching tool results, one signed call, at least two unsigned calls, final text, and no skip_thought_signature_validator warning.',
);
}
}

main().catch(error => {
console.error(error);
process.exitCode = 1;
});
110 changes: 110 additions & 0 deletions packages/google/src/convert-to-google-messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1686,6 +1686,116 @@ describe('Gemini 3 missing thoughtSignature mitigation', () => {
expect(onWarning.mock.calls[0][0].message).toContain('`weather`');
});

it('does NOT inject the sentinel or warn for a documented Gemini 3 parallel function-call batch with only the first call signed', () => {
const onWarning = vi.fn();
const result = convertToGoogleMessages(
[
{
role: 'user',
content: [
{
type: 'text',
text: 'Check the weather in Paris, Tokyo, and New York.',
},
],
},
{
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'tc_paris',
toolName: 'get_weather',
input: { city: 'Paris' },
providerOptions: {
googleVertex: { thoughtSignature: 'sig_parallel_batch' },
vertex: { thoughtSignature: 'sig_parallel_batch' },
},
},
{
type: 'tool-call',
toolCallId: 'tc_tokyo',
toolName: 'get_weather',
input: { city: 'Tokyo' },
},
{
type: 'tool-call',
toolCallId: 'tc_new_york',
toolName: 'get_weather',
input: { city: 'New York' },
},
],
},
{
role: 'tool',
content: [
{
type: 'tool-result',
toolCallId: 'tc_paris',
toolName: 'get_weather',
output: {
type: 'json',
value: { city: 'Paris', tempC: 21, conditions: 'sunny' },
},
},
{
type: 'tool-result',
toolCallId: 'tc_tokyo',
toolName: 'get_weather',
output: {
type: 'json',
value: { city: 'Tokyo', tempC: 24, conditions: 'sunny' },
},
},
{
type: 'tool-result',
toolCallId: 'tc_new_york',
toolName: 'get_weather',
output: {
type: 'json',
value: { city: 'New York', tempC: 19, conditions: 'sunny' },
},
},
],
},
],
{
isGemini3Model: true,
providerOptionsNames: ['googleVertex', 'vertex'],
onWarning,
},
);

const assistant = result.contents.find(c => c.role === 'model');
expect(assistant?.parts).toStrictEqual([
{
functionCall: {
id: 'tc_paris',
name: 'get_weather',
args: { city: 'Paris' },
},
thoughtSignature: 'sig_parallel_batch',
},
{
functionCall: {
id: 'tc_tokyo',
name: 'get_weather',
args: { city: 'Tokyo' },
},
thoughtSignature: undefined,
},
{
functionCall: {
id: 'tc_new_york',
name: 'get_weather',
args: { city: 'New York' },
},
thoughtSignature: undefined,
},
]);
expect(onWarning).not.toHaveBeenCalled();
});

it('does NOT inject the sentinel for non-Gemini-3 models', () => {
const onWarning = vi.fn();
const result = convertToGoogleMessages(promptWithToolCallMissingSignature, {
Expand Down
18 changes: 17 additions & 1 deletion packages/google/src/convert-to-google-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,10 +335,16 @@ export function convertToGoogleMessages(
case 'assistant': {
systemMessagesAllowed = false;

let currentToolCallBatchHasRealThoughtSignature = false;

contents.push({
role: 'model',
parts: content
.map(part => {
if (part.type !== 'tool-call') {
currentToolCallBatchHasRealThoughtSignature = false;
}

const providerOpts = readProviderOpts(part);
const thoughtSignature =
providerOpts?.thoughtSignature != null
Expand Down Expand Up @@ -457,12 +463,22 @@ export function convertToGoogleMessages(
providerOpts?.serverToolType != null
? String(providerOpts.serverToolType)
: undefined;
const shouldSkipMissingSignatureMitigation =
// Gemini 3 can return a single signature for a parallel
// function-call batch on the first call. Subsequent calls
// in that batch legitimately have no signature to replay.
thoughtSignature == null &&
currentToolCallBatchHasRealThoughtSignature;

const effectiveThoughtSignature =
thoughtSignature ??
(isGemini3Model
(isGemini3Model && !shouldSkipMissingSignatureMitigation
? injectSkipSignature(part.toolName)
: undefined);

currentToolCallBatchHasRealThoughtSignature ||=
thoughtSignature != null;

if (serverToolCallId && serverToolType) {
return {
toolCall: {
Expand Down