diff --git a/.changeset/gemini-parallel-tool-calls.md b/.changeset/gemini-parallel-tool-calls.md new file mode 100644 index 000000000000..ca020d6ca62d --- /dev/null +++ b/.changeset/gemini-parallel-tool-calls.md @@ -0,0 +1,5 @@ +--- +"@ai-sdk/google": patch +--- + +fix(google): avoid Gemini 3 missing-signature warnings for signed parallel tool call batches diff --git a/examples/ai-functions/src/reproduction/issue-16298-gemini3-parallel-tool-calls.ts b/examples/ai-functions/src/reproduction/issue-16298-gemini3-parallel-tool-calls.ts new file mode 100644 index 000000000000..6d73ad82e4c9 --- /dev/null +++ b/examples/ai-functions/src/reproduction/issue-16298-gemini3-parallel-tool-calls.ts @@ -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; +}); diff --git a/packages/google/src/convert-to-google-messages.test.ts b/packages/google/src/convert-to-google-messages.test.ts index 4ca10e6a4fa4..ee6a5e8d0c17 100644 --- a/packages/google/src/convert-to-google-messages.test.ts +++ b/packages/google/src/convert-to-google-messages.test.ts @@ -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, { diff --git a/packages/google/src/convert-to-google-messages.ts b/packages/google/src/convert-to-google-messages.ts index 2f4fe76e84c1..db03935b3f3c 100644 --- a/packages/google/src/convert-to-google-messages.ts +++ b/packages/google/src/convert-to-google-messages.ts @@ -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 @@ -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: {